# Monday
db = sqlite3.connect("wordbank.db")
db.execute("UPDATE entries SET lookups = lookups + 1 WHERE headword = ?", ("mizzle",))
# ~20 microseconds. No network. No auth. No connection pool.
# Friday, after "we should put this in the cloud"
requests.post(API, json={"namespace": "wordbank", "protocol": "sqlite",
"query": "UPDATE entries SET lookups = lookups + 1 ..."})
# ~50 milliseconds. Roughly two thousand times slower, and the transaction
# you were about to open cannot survive to the next line.Hosted SQLite is a real and useful thing. It is also the database category where the gap between what people expect and what they get is widest, because SQLite's defining property — it is a file your process opens — is exactly the property hosting removes.
This repository is about that gap: what actually changes, what to do about it, and the cases where the correct answer is to stop and keep the file. Where a hosted example is needed, it runs against a free hosted instance.
Four reasons, all legitimate:
More than one machine needs the data. A laptop, a CI runner and a small deployed service cannot share a file. This is the honest majority case and the only one where hosting is unambiguously right.
The runtime has no filesystem. Edge functions, serverless handlers and browser-adjacent code often cannot open a file, or get one that vanishes between invocations.
Somebody else has to look at it. Sending a colleague a .db file and hoping they have a client
is worse than sending them a URL.
An assistant needs to query it. A model cannot open a file on your laptop. It can call a tool against an endpoint.
Notice what is not on that list: scale. If you are considering hosted SQLite because SQLite is "too small", you want PostgreSQL, and the sooner you accept that the cheaper the migration is.
SQLite serialises writes. WAL mode lets readers continue during a write, which people often mis-remember as "WAL gives you concurrent writes". It does not. Nothing does. There is one write lock and every writer queues for it.
Locally this is nearly free — the lock is held for microseconds and contention is invisible. Hosted, each write holds that lock across a network round trip, and the queue that used to be imperceptible becomes the throughput ceiling for your whole application. The engine behaves identically. The arithmetic does not.
This is the one that changes your code, not just your latency.
db.execute("BEGIN IMMEDIATE")
row = db.execute("SELECT lookups FROM entries WHERE headword = 'mizzle'").fetchone()
db.execute("UPDATE entries SET lookups = ? WHERE headword = 'mizzle'", (row[0] + 1,))
db.execute("COMMIT")Locally, correct. Over a stateless HTTP API, three separate requests with no shared transaction
context: your BEGIN and your COMMIT may not even reach the same handler, and between the
SELECT and the UPDATE anyone can write.
Two ways out. Either send the whole unit of work as one statement so the engine does the atomicity —
UPDATE entries SET lookups = lookups + 1 WHERE headword = 'mizzle', which is atomic on its own and
is what you should have written anyway — or send a multi-statement script in
a single request so it executes as one transaction server-side. What you cannot do is interleave application logic with an
open transaction. Read-modify-write across round trips is the bug this category produces most often.
The performance model inverts. Locally, the cost of a query is the work it does, and the number of queries barely matters. Hosted, the cost is dominated by the round trip, and the number of queries is the only thing that matters.
That turns a familiar non-problem into a serious one. A loop that fetches a row per iteration is a
mild inefficiency against a local file and a disaster against an HTTP endpoint — a hundred iterations
is a hundred round trips. JOIN, IN (...), and returning one result set instead of a hundred stop
being style preferences and become the difference between 50ms and 5 seconds.
The rule that follows: count round trips, not queries. If you can express something as one statement, do, even when the single statement is uglier.
concurrency_demo.py runs N concurrent connections incrementing one counter
and reports what SQLite does about it. No credentials, no network — it uses a temporary local file,
because the lock semantics are a property of the engine and hosting only adds latency on top.
python3 concurrency_demo.py # 8 writers, WAL, 5s busy_timeout
python3 concurrency_demo.py --busy-timeout 0 # no patience: watch retries appear
python3 concurrency_demo.py --journal delete # the pre-WAL rollback journal
python3 concurrency_demo.py --compare # all four combinations, one table--compare from one laptop, running SQLite 3.45.1:
journal timeout committed final retries commits/s
-----------------------------------------------------------
wal 5000 320 320 0 11196
wal 0 320 320 17 13723
delete 5000 320 320 0 893
delete 0 320 320 88 1270
These are one machine's numbers, not a benchmark of anything — run it yourself and you will get different ones. The shape is what matters, and there are three things in it:
finalalways equalscommitted. SQLite did not lose an increment or corrupt a row under any configuration. Correctness was never the question.- WAL is dramatically faster than the rollback journal under contention, which is why it is the default worth setting. It still did not let two writers write at once — it let readers not block.
- Dropping
busy_timeoutto zero produced retries, not errors, only because the demo retries in a loop. A client that does not retry getsSQLITE_BUSYand, if it swallows that, silently loses the write. That is the failure mode: not a crash, a number that is quietly wrong.
Raise --writers far enough with --busy-timeout 0 and you will see the ABANDONED line appear.
Every abandoned update is data your application believed it had stored.
| HTTP query API | Embedded replica (libSQL / Turso) | Ship the file | |
|---|---|---|---|
| Read latency | One round trip per query | Local — reads hit a file on disk | Local |
| Write latency | One round trip | One round trip to the primary | Local |
| Multiple machines | Yes | Yes | No |
| Multi-statement transactions | Only as one batched request | Yes, against the replica's local copy | Yes |
| Offline | No | Reads work; writes queue or fail | Yes |
| Setup cost | A URL | A client library and a sync story | Nothing |
The HTTP query API is the simplest thing that can work, and the one this repository's examples use. Every statement is a request; nothing is installed; anything that can make an HTTPS call is a client, including an AI assistant. The cost is the round trip and the loss of client-held transactions.
Embedded replicas are the more sophisticated answer, and Turso is the reference implementation. You keep a local copy of the database that your process reads at file speed, and writes go to the primary and replicate back. For read-heavy workloads this is strictly better than an HTTP API — reads stop costing anything at all. Turso has invested more in hosted SQLite than anyone else: the libSQL fork, embedded replicas, and a from-scratch rewrite of the engine in Rust. If your workload is read-dominated and you can adopt a client library, look there first.
The trade is that you have adopted a library, a sync model, and a local file that can be stale. The HTTP approach has no client to install and no consistency question — it is slower and simpler in exactly the places the replica approach is faster and more complicated.
Shipping the file is not a joke option. For reference data that changes on a deploy cadence — a
lookup table, a documentation index, a pricing catalogue — baking a read-only .db into your
container image gives you zero-latency reads, no service to be down, and no bill. If nothing writes
at runtime, hosting buys you nothing.
Say it plainly, because a repository about hosted SQLite has an obvious incentive not to.
- One process writes, and it is the only one. A desktop app, a CLI tool, a single-instance service with a persistent volume. Hosting adds latency and a dependency and removes nothing.
- The data is read-only at runtime. Ship it. A file in a container image cannot have an outage.
- It is a test fixture.
:memory:or a temp file per test. A shared hosted database is the enemy of test isolation and you will spend more time on cleanup than the tests take to run. - You are teaching SQL. A file and the
sqlite3binary have no failure modes involving networks, tokens or somebody's corporate proxy. - Latency matters more than sharing. If a request path does twenty queries and you cannot reduce it, twenty round trips will cost more than every optimisation you have made this year saved.
If none of those describe you, hosting is reasonable. If one of them does, the honest advice is to keep the file and revisit this when the constraint changes.
freebase.cloud runs SQLite 3.45.1 behind an HTTP query API and an MCP endpoint. No credit card. Free tier, suited to development, prototyping and small production workloads — which for SQLite is a wider band than it sounds, because SQLite's workloads are usually small by construction.
Be clear about what you get: there is no raw socket and no file handle. You send statements and receive results.
curl -X POST https://freebase.cloud/api/wire/query \
-H "Content-Type: application/json" \
-d '{"namespace":"wordbank","protocol":"sqlite","query":"SELECT sqlite_version()"}'For assistants, take a token from
Settings → MCP → New Token and register the endpoint. The
connection name prefixes the tools; this repository uses wordbank.
claude mcp add --transport http wordbank https://freebase.cloud/api/mcp/YOUR_TOKEN| Tool | Use |
|---|---|
wordbank_query |
SELECT, including CTEs, window functions and FTS5 MATCH |
wordbank_store |
INSERT / UPDATE / DELETE |
wordbank_list_tables |
What exists, without the model guessing |
wordbank_annotate_table |
Describe a table once so later sessions do not re-derive it |
SQLite connections additionally expose
sqlite_master and sqlite_version helpers, which is the
fastest way to get an assistant to read the real schema instead of inventing a plausible one.
- Type affinity is not type checking. A
TEXTcolumn will accept an integer. If you want the constraint, write it:CHECK (typeof(region) = 'text'). AUTOINCREMENTis usually wrong.INTEGER PRIMARY KEYalready aliases the rowid and reuses gaps;AUTOINCREMENTonly adds a monotonicity guarantee and a second table to maintain.- FTS5 is excellent and separate.
CREATE VIRTUAL TABLE ... USING fts5(...)gives you real ranking viabm25(). Use an external-content table plus triggers so the text is not stored twice —examples/wordbank.sqldoes exactly that, and the same virtual table works on the hosted build. - Dates are text, numbers or nothing. There is no date type. Store ISO-8601 strings, use
datetime(), and be consistent — the sort order of a well-formed ISO string is the sort order of the date, which is why the convention works. PRAGMA foreign_keysdefaults to off in many drivers. Your foreign keys are decorative until you turn it on.
| File | What it does |
|---|---|
concurrency_demo.py |
Concurrent writers against a local file; prints contention, retries and lost updates |
examples/wordbank.sql |
A real schema: entries, citations, FTS5 with external content, triggers, useful queries |
examples/http_query.sh |
The HTTP query API end to end, including the round-trip counting lesson |
examples/README.md |
How to run both, locally and hosted |
- SQLite: file locking and concurrency and Write-Ahead Logging — the primary sources for everything in the concurrency section above.
- SQLite: appropriate uses — the authors' own honest account of where their database does and does not belong. Worth reading before any hosting decision.
- FTS5 — external content tables and
bm25(). - Turso — libSQL, embedded replicas, and the Rust rewrite of the engine.
- freebase.cloud SQLite · connecting Claude to SQLite
Third-party details last verified: 2026-08-18.
freebase.cloud is an independent service and is not affiliated with the SQLite project, Hipp, Wyrick & Company, Inc., Anthropic, PBC, or Turso.