$ python3 tools/portability.py --sql dump.sql
parsed 812 statements from dump.sql (2411907 bytes)
BLOCKER line 44 libsql_vector_idx()
CREATE INDEX embeddings_idx ON embeddings (libsql_vector_idx(vec));
→ libSQL's vector index. There is no drop-in on stock SQLite; you would
need an extension such as sqlite-vec, or to move vector search elsewhere.
BLOCKER line 39 libSQL vector types/functions
CREATE TABLE embeddings ( sensor_id INTEGER PRIMARY KEY, vec F32_BLOB(64) );
summary: 2 blocker(s), 4 to review, 5 note(s)
Two questions come up when people think about moving off Turso, and they get asked in the wrong order. The usual first question is "where else can I host SQLite". The one that actually decides the outcome is "is this still SQLite?" — because libSQL is a fork, and forks add things.
This repo answers the second question with a checker, then does the move onto
a plain hosted SQLite with
a .dump → HTTP importer that verifies its own work.
- The checker — what counts as a blocker, and why
- The importer — filtering a dump, and the virtual-table trap
- Verification — column fingerprints, computed identically on both sides
- Undoing an import
- What Turso is genuinely good at
- Keep Turso if…
- SQLite on freebase.cloud
python3 tools/portability.py --db ./local.db # a file
python3 tools/portability.py --sql dump.sql # or a dumpNo dependencies. It uses the SQLite that ships with Python as the reference
implementation, which is the whole trick: if stock SQLite cannot compile a
statement, nothing claiming SQLite compatibility will either. Every CREATE
and ALTER in the input is run through a throwaway in-memory database, and
whatever it rejects becomes a blocker regardless of what the pattern rules
think.
On top of that, named checks:
| Severity | Flagged | Why |
|---|---|---|
| BLOCKER | CREATE FUNCTION |
libSQL user-defined functions, including the WASM form. No SQLite equivalent. |
| BLOCKER | ALTER TABLE … ALTER COLUMN |
libSQL extends ALTER TABLE. Stock SQLite has ADD / RENAME / DROP COLUMN only. |
| BLOCKER | RANDOM ROWID |
A libSQL table option. |
| BLOCKER | F32_BLOB, vector32(), vector_distance_cos(), libsql_vector_idx() |
libSQL's native vector support. |
| BLOCKER | load_extension() |
Disabled in most builds, impossible over an HTTP query API. |
| BLOCKER | CREATE VIRTUAL TABLE … USING <unknown> |
Anything outside the fts/rtree/geopoly/dbstat/json set comes from a loadable extension. |
| REVIEW | INSERT INTO sqlite_master |
How a dump encodes a virtual table. See below. |
| REVIEW | ATTACH DATABASE |
Assumes a filesystem; there is one database per HTTP request. |
| REVIEW | PRAGMA … |
Per-connection state, which a stateless API does not carry between requests — foreign_keys included. |
| REVIEW | AUTOINCREMENT |
Implies sqlite_sequence; do not replay inserts into it. |
| NOTE | WITHOUT ROWID, STRICT, generated columns |
Version requirements, all satisfied by 3.45. |
Exit code 1 on any blocker. The point is to find out in four seconds rather than four hours in. A clean run means the dump replays into a stock SQLite 3.45.1 instance without edits.
python3 tools/dump_import.py --sql dump.sql --dry-run
python3 tools/dump_import.py --sql dump.sql --apply--apply sends the filtered statements to
the SQLite connection you created
over its HTTP query API.
It parses the dump properly rather than splitting on ; — tools/sqlparts.py
tracks single quotes with '' escapes, X'…' blobs, double-quoted, bracketed
and backticked identifiers, -- and /* */ comments, and CREATE TRIGGER
bodies where the semicolons inside BEGIN … END are not statement
terminators. Run it standalone to see what it made of your file:
python3 tools/sqlparts.py dump.sql | headThen it filters:
- transaction control, because batching happens per HTTP request
- pragmas, because they are per-connection
- inserts into
sqlite_sequenceand thesqlite_stat*tables, because SQLite maintains those itself
This is the part that costs people an afternoon. A .dump of a database
containing an FTS5 table does not emit CREATE VIRTUAL TABLE. It emits:
PRAGMA writable_schema=ON;
INSERT INTO sqlite_master(type,name,tbl_name,rootpage,sql)
VALUES('table','notes_fts','notes_fts',0,'CREATE VIRTUAL TABLE notes_fts USING fts5(...)');
INSERT INTO "notes_fts" VALUES('SN-0002','water ingress at the gland');
CREATE TABLE 'notes_fts_data'(id INTEGER PRIMARY KEY, block BLOB);
INSERT INTO "notes_fts_data" VALUES(1,X'03061A');
...Replayed statement by statement, that fails with no such table: notes_fts —
the row was written into sqlite_master but the connection's schema cache is
stale, and the shadow tables it would need do not exist yet. Worse, the rows
appear twice: once as inserts into the virtual table, once as the raw
shadow tables further down.
Two strategies, both explicit:
--vtab-strategy writable-schema (default). Keeps the writable_schema
wrapper, keeps the shadow tables, drops the redundant direct inserts, and
guarantees the whole ON … OFF region travels in a single HTTP request. Data
is preserved exactly, including the built index.
--vtab-strategy rebuild. Rewrites the sqlite_master row back into a
real CREATE VIRTUAL TABLE and drops the shadow tables. Use this when the
remote API cannot be relied on to run a batch on one connection. It prints a
warning in capitals, because the virtual table arrives empty and the
fingerprint check will pass anyway — both sides being equally empty is still a
match. You have to repopulate it from the base table yourself.
Nothing about that is a libSQL problem or a hosting problem. It is how SQLite dumps virtual tables, and any tool that does not mention it is going to lose your search index quietly.
The importer builds a scratch SQLite database locally from the same filtered
statement list it sends to the remote
instance, then reopens it — that reopen is what
clears the stale schema cache the sqlite_master write left behind.
That local database is the reference. For every table:
SELECT count(*),
count("col1"), coalesce(total(length(quote("col1"))), 0),
count("col2"), coalesce(total(length(quote("col2"))), 0)
FROM "table";The identical statement runs on both sides. It is order-independent, so row ordering never causes a false alarm, and it catches truncation, NULL drift and type changes. It is a fingerprint, not a hash — it will not detect an adversary who swaps two values of equal quoted length, and it does not pretend to.
table reference remote
--------------------------------------------------------------
sites [3.000, 3.000, 3.000] [3.000, 3.000, 3.000]
sensors [5.000, 5.000, 5.000] [5.000, 5.000, 5.000]
readings [6.000, 6.000, 6.000] [6.000, 6.000, 6.000]
notes_fts [3.000, 3.000, 27.000] [3.000, 3.000, 27.000]
VERIFY OK — row counts and column fingerprints agree on every table.
Re-run it any time with --verify-only; it does not need the import to have
happened in the same process.
--apply records every table it created in sqlite-import-state.json.
python3 tools/dump_import.py --rollback --yesThat issues DROP TABLE against the hosted
database for exactly those names. It will not drop a table it
did not create, and without --yes it only prints what it would do. If the
import failed part way, the state file already lists what got through — the
error message points you at this command.
Last verified: 2026-08-18 against turso.tech.
| Turso | A hosted SQLite over HTTP | |
|---|---|---|
| Embedded replicas | Yes — a local copy synced from the cloud, reads served from local disk | No. Every read is a network round trip |
| Edge read latency | Replicas placed near users | Single origin |
| Offline writes | Write locally, reconcile on sync | No |
| Open source | libSQL is a fork of SQLite that accepts contributions (github.com/tursodatabase/libsql); the newer Rust rewrite is MIT-licensed | SQLite itself is public domain |
| Vector search | Built into libSQL (F32_BLOB, libsql_vector_idx) |
Not in stock SQLite |
| Free plan | Starter: 100 databases, 5 GB storage, 500M rows read and 10M rows written monthly, 3 GB syncs, 1-day point-in-time restore | n/a |
Three things worth stating without hedging:
- Embedded replicas are hard to match and easy to underrate. Reading from a local file and syncing in the background is a genuinely different latency profile from any HTTP database, and if your application depends on it, no amount of "SQLite is SQLite" changes that.
- libSQL being open source matters. It is a fork you can run yourself, which is a real answer to lock-in worries and more than most hosted databases offer. The extensions this repo flags as blockers are not traps — they are features, published in the open, and they are only a problem when you want to leave.
- Turso's free plan is not stingy. The numbers above are generous for a side project. If you are shopping purely on free-tier size, this repo is probably not the argument you are looking for.
- You use embedded replicas. This is the clearest case. There is no migration in this repository that gives you local-disk reads with background sync, and pretending an HTTP endpoint is equivalent would be a lie you discover under load.
- Your schema has vector columns.
F32_BLOBandlibsql_vector_idxare blockers for a reason. Moving means adopting an extension such as sqlite-vec on a server you control, or moving vector search to a different system entirely. That is a project, not a migration. - You write from an offline client. Local-first sync semantics are the product. Rebuilding them on top of a request/response API is a distributed systems exercise.
- You run hundreds of databases per tenant. The database-per-user pattern is something Turso is explicitly built for. One database is one database.
If none of those apply and you are using Turso as "SQLite with a URL", the migration is genuinely small — which is exactly what the checker is for: finding out which of those two situations you are in.
Make an account at freebase.cloud, start a session and select SQLite. The free SQLite instance page covers the specifics.
SQLite 3.45.1 — the same engine version as the Python interpreter this repo's tooling runs on, which is why the local reference database is a fair comparison rather than an approximation. Reached over an HTTP query API rather than a file handle:
curl -X POST https://freebase.cloud/api/wire/query \
-H "Content-Type: application/json" \
-d '{"namespace":"sensors","protocol":"sqlite","query":"SELECT sqlite_version()"}'CTEs, window functions, json_extract and the JSON operators, FTS5 with
bm25() ranking, partial indexes, STRICT tables and generated columns all
behave as the SQLite documentation says, because it is SQLite. What you do not
get is a file on your disk — no ATTACH, no per-connection pragmas that
persist, no extension loading. That is the trade the checker exists to make
visible.
As with the rest of the free tier: development, prototyping, and production workloads small enough that you could rebuild them if you had to.
In the dashboard: Settings → MCP → New Token, pick the connection, copy the URL. For the Gemini CLI:
gemini mcp add --transport http edge https://freebase.cloud/api/mcp/YOUR_TOKENIf you edit settings.json by hand, the key is httpUrl — plain url
means SSE there, which is the classic way to end up with a server that never
connects:
{ "mcpServers": { "edge": { "httpUrl": "https://freebase.cloud/api/mcp/YOUR_TOKEN", "timeout": 5000 } } }Note that custom MCP servers are a Gemini CLI and Gemini Enterprise feature; the consumer Gemini app does not support them.
The connection exposes edge_query, edge_store, edge_list_tables and
edge_annotate_table, plus sqlite_master and sqlite_version helpers on
SQLite connections specifically — handy for letting a model discover the schema
before it writes a query. Claude setup:
how to connect Claude to SQLite.
tools/sqlparts.py statement splitter (quotes, comments, trigger bodies)
tools/portability.py blockers / review / notes, plus a real compile pass
tools/dump_import.py filter → send → fingerprint → rollback
examples/field_sensors.sql schema with every awkward SQLite feature
examples/check_and_load.sh dump → check → dry run → confirm → import → verify
MIT licensed.
freebase.cloud is an independent service and is not affiliated with Turso, the SQLite project, Google or Anthropic.