Connect from Python

Last updated 29 October 2026

View .md

Connect a Python program to a database once you have created one: open it by its URL with an API key, commit, and call synced() before anything outside the process acts.

sqlite_ursula.open(), from the sqlite-ursula package, returns a subclass of sqlite3.Connection, so your SQL, and a library that takes a sqlite3 connection, work as they do on a local file. Install the package as the Python quickstart does, and put the database’s URL in NOTES_URL:

export NOTES_URL=$(tonbo database show notes --json | jq -r .url)
app.py
import os

import sqlite_ursula

# Opens notes by its URL with your API key; db is a sqlite3.Connection.
db = sqlite_ursula.open(
    os.environ["NOTES_URL"],
    token=os.environ["TONBO_API_KEY"],
    dir="./notes-cache",
)
db.execute("CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY, body TEXT)")
db.execute("INSERT INTO notes(body) VALUES (?)", ("hello",))
db.commit()  # committed on this machine
db.synced()  # Tonbo now holds every commit made so far
print(db.execute("SELECT count(*) FROM notes").fetchone()[0], db.status()["commits"])
db.close()
TONBO_API_KEY=tbo_... python app.py

A database’s URL looks like https://db.tonbo.dev/<account-id>/notes: tonbo database show, the database’s page in the console and the Management API show it (see Regions and URLs).

Requirements

  • CPython 3.11 or later, the free-threaded builds included, in a process that keeps the database open while it works. A short-lived process works too, and rebuilds the local copy at each start (see Short-lived processes). See Where it runs for the machines that fit.
  • A Python whose sqlite3 module can load SQLite extensions and runs a recent SQLite, since the package runs inside it. The table below says which do.
  • One of the platforms the package ships for (below). On any other, such as an Intel Mac, pip installs it without the part that runs inside SQLite, and open() fails.
  • A local disk for the cache directory, the dir argument, not a network filesystem.
  • sqlite-ursula==0.11.3.dev0, pinned exactly. Python uses the pre-release sqlite-ursula 0.11.3.dev0 until 0.11.3 is published to PyPI.
PythonRuns the package
uv-managed CPython (uv python install), Homebrew, conda-forgeYes
The python:3.x-alpine images (Alpine 3.23 or later), on x64Yes
The python:3.x-slim images, Debian’s and Ubuntu’s apt Python, RHEL and UBI, Amazon Linux, ColabNo: their SQLite is too old
The python.org macOS installer, Apple’s /usr/bin/python3, pyenv’s default buildNo: built without loadable SQLite extensions
WindowsNo: run it under WSL
WheelPlatform
macosx_11_0_arm64macOS 11 or later, Apple silicon
manylinux_2_28_x86_64Linux x64, glibc 2.28 or later
manylinux_2_28_aarch64Linux arm64, glibc 2.28 or later
musllinux_1_2_x86_64Linux x64, musl (Alpine 3.23 or later)

python -m sqlite_ursula checks the Python it runs in and prints result: ok or what to use instead. On a Python that cannot run the package, open() raises UrsulaSetupError with the kind host_unsupported, whose fix says the same. On a laptop, a uv-managed CPython is the usual answer. In Docker, switch the base image to Alpine:

Dockerfile
-FROM python:3.13-slim
+FROM python:3.13-alpine

To keep a Debian base, on arm64, or with LangGraph, which does not install on Alpine, run a uv-managed CPython in the image instead, as the package’s Docker notes show.

The API key

Pass the key as token. A key that acts as you is sent to Tonbo as it is, with no exchange (see Sending the key directly), so a string is all open() needs, and it serves for as long as the key lives. The process holds one token for every database it opens: open each with the same key, or call sqlite_ursula.set_token(key) to replace it.

A key that acts as an Agent is never accepted directly: Tonbo refuses it with 401 exchange_required. Exchange it first at POST https://tonbo.dev/api/iam/token for an access token that lasts 10 minutes, and pass that token as token (see The token exchange). The package does not renew it: before it expires, exchange the key again and pass the new token to sqlite_ursula.set_token(), or commits wait until you do.

open()

The first open() of a database in a process catches its local copy up from Tonbo and makes this process the database’s writer. Its arguments, after the URL:

  • token: the key, or an access token exchanged from it (above).
  • dir: the cache directory, where the local copy lives. By default it is under your user cache directory.
  • timeout: how long the open may take, 30 seconds by default. Past it, open() raises UrsulaUnavailableError, whose kind says what it last met, such as unreachable or unauthorized. Ctrl-C cancels it too.
  • read_only=True: read without becoming the writer. Writes fail with SQLITE_READONLY, and the process writing the database is left alone.
  • busy_timeout: how long a statement waits for another connection’s lock, 5 seconds by default. Every other keyword goes to sqlite3.connect, and check_same_thread is False unless you pass it.

Keep the default transaction control, isolation_level=None or autocommit=True: autocommit=False is refused, since it would hide a refused commit. db.close() closes the connection; once the last one closes, the commits left keep replicating in the background. The package’s README lists every argument.

synced()

A commit returns once it is on this machine. db.synced() returns once Tonbo holds every commit the process made on the database before the call. Call it before anything outside the process acts on what you wrote or read, such as a response or a queue acknowledgement:

db.execute("INSERT INTO notes(body) VALUES (?)", (body,))
db.commit()
db.synced()  # returns once Tonbo holds the commit
send(reply)

It does not raise for a network failure: it waits until Tonbo is reachable again. db.synced(timeout=5) raises TimeoutError after 5 seconds while the commits keep replicating. sqlite_ursula.gate(db) does the same wait for a block of work: leaving the block waits for a synced() made after it ended, on every database you name, before the result or the block’s error goes on.

with sqlite_ursula.gate(db):
    reply = handle(request)  # commits through db
send(reply)  # runs once Tonbo holds every commit the block made

See When a commit is durable. To make a call to another service once across crashes and second instances, see Run side effects once, and for the other rules, Rules for databases.

asyncio

aopen() takes the arguments of open(), and await db.asynced() is synced(). Both wait on the event loop instead of blocking it, and async with sqlite_ursula.agate(db) is the gate. Statements still run on the calling thread, as they do with sqlite3.

async def main() -> None:
    db = await sqlite_ursula.aopen(
        os.environ["NOTES_URL"],
        token=os.environ["TONBO_API_KEY"],
        dir="./notes-cache",
    )
    db.execute("INSERT INTO notes(body) VALUES (?)", ("hello",))
    db.commit()
    await db.asynced()  # waits on the event loop, without blocking it
    await send(reply)

Connections and frameworks

Each later open() of the database in the process returns another connection to it with no network, and any thread may call it. A framework that opens its own sqlite3 connection by path works on the same database: give it db.path, and keep db open while it works. Its commits replicate too, and db.synced() covers them:

other = sqlite3.connect(db.path)  # replicates too, and db.synced() covers it

Worker processes

Open databases after a fork, never before: replication does not survive fork(), and in a child forked after its parent opened a database, the package raises UrsulaSetupError with the kind forked. Open them in gunicorn’s post_fork hook or Celery’s worker_process_init signal, or use multiprocessing’s spawn or forkserver start method. Importing the package before the fork is fine.

Open on another host

Stop the process on the old host, then open the same database on the new one, with the same key. The open rebuilds the local copy from Tonbo and makes the new process the writer. If the old process still runs, its statements and synced() raise UrsulaResetError from then on. Do not copy the cache directory to another host. See Open on another host for Kubernetes.

LangGraph

sqlite_ursula.langgraph holds LangGraph’s SQLite checkpointers and stores on a database: UrsulaSaver, AsyncUrsulaSaver, UrsulaStore and AsyncUrsulaStore. Install them with the langgraph extra:

uv pip install 'sqlite-ursula[langgraph]==0.11.3.dev0'
from sqlite_ursula.langgraph import UrsulaSaver

with UrsulaSaver(db) as saver:
    graph = builder.compile(checkpointer=saver)
    reply = graph.invoke(inputs, {"configurable": {"thread_id": "1"}})
send(reply)  # every checkpoint of the run is in Tonbo

Every checkpoint write waits for Tonbo, so when invoke returns, the run’s checkpoints are durable. A streamed chunk is not: call db.synced() before acting on one. The package’s LangGraph section has the details.

Errors

Every error the package raises has a kind, what failed, and an action, what to do: retry, reopen, stop or fix. Branch on action.

ClassRaised by
UrsulaWriteRefusedErrorA write or a commit Tonbo refused, with nothing committed, such as backlog_full: call synced(), then retry.
UrsulaResetErrorEvery statement after the database was reset, such as when another process took it over, and synced().
UrsulaUnavailableErroropen() past its timeout.
UrsulaSetupErroropen() on a Python that cannot run the package, a wrong argument or URL, or a database that does not exist.

UrsulaWriteRefusedError and UrsulaResetError are also sqlite3.OperationalError, so existing handlers keep catching them. Troubleshooting says what each kind means and what to do about it.

Status

db.status() returns the database’s state as a dict, with the fields Status lists, such as commits.pending, commits on this machine that Tonbo does not hold yet, and replication.condition, why they wait.