What we're building

Three sandboxes sharing a single SQLite database through a Chalk volume:

  1. A writer sandbox creates the database, inserts rows, and terminates.
  2. Two reader sandboxes start afterward, mount the same volume, and independently query the data.

This proves that data written to a volume outlives the sandbox that created it — and that multiple sandboxes can read it concurrently. The pattern is useful for experiment tracking, lightweight coordination state, or any case where you want persistence without standing up a database server.


Shared configuration

All three sandboxes use the same slim Python image. SQLite ships with Python’s standard library, so no extra packages are needed.

from chalkcompute import Image, Sandbox, Volume

VOLUME_NAME = "sqlite-persistence"
MOUNT_PATH = "/data"
DB_PATH = f"{MOUNT_PATH}/app.db"

image = Image.debian_slim("3.12")

The volume is the only piece of shared state. Each sandbox mounts it at /data and accesses the database at /data/app.db.


Step 1 — Write the database

The writer sandbox creates a users table and inserts three rows. After the script finishes, the sandbox is terminated — the data now lives only on the volume.

writer = Sandbox(
    image=image,
    name="sqlite-writer",
    volumes=[(VOLUME_NAME, MOUNT_PATH)],
).run()

result = writer.exec("python3", "-c", """
import sqlite3, os

db_path = "/data/app.db"
os.makedirs(os.path.dirname(db_path), exist_ok=True)

conn = sqlite3.connect(db_path)
conn.execute(
    "CREATE TABLE IF NOT EXISTS users "
    "(id INTEGER PRIMARY KEY, name TEXT, email TEXT)"
)
conn.execute("INSERT OR IGNORE INTO users VALUES (1, 'Alice', 'alice@example.com')")
conn.execute("INSERT OR IGNORE INTO users VALUES (2, 'Bob', 'bob@example.com')")
conn.execute("INSERT OR IGNORE INTO users VALUES (3, 'Charlie', 'charlie@example.com')")
conn.commit()

rows = conn.execute("SELECT * FROM users ORDER BY id").fetchall()
conn.close()

print(f"Wrote {len(rows)} rows to {db_path}")
for r in rows:
    print(f"  {r}")
""")

print(result.stdout_text)
# Wrote 3 rows to /data/app.db
#   (1, 'Alice', 'alice@example.com')
#   (2, 'Bob', 'bob@example.com')
#   (3, 'Charlie', 'charlie@example.com')

writer.terminate()

At this point the writer is gone. The volume still holds app.db.


Step 2 — Read from independent sandboxes

Start two new sandboxes that mount the same volume. Each opens the database in read-only mode and queries it independently — proving the data survived the writer’s shutdown.

reader_a = Sandbox(
    image=image,
    name="sqlite-reader-a",
    volumes=[(VOLUME_NAME, MOUNT_PATH)],
).run()

reader_b = Sandbox(
    image=image,
    name="sqlite-reader-b",
    volumes=[(VOLUME_NAME, MOUNT_PATH)],
).run()

READ_SCRIPT = """
import sqlite3

conn = sqlite3.connect("file:/data/app.db?mode=ro", uri=True)
rows = conn.execute("SELECT * FROM users ORDER BY id").fetchall()
conn.close()

for r in rows:
    print(r)
print(f"Read {len(rows)} rows")
"""

for name, reader in [("reader-a", reader_a), ("reader-b", reader_b)]:
    result = reader.exec("python3", "-c", READ_SCRIPT)
    print(f"--- {name} ---")
    print(result.stdout_text)
--- reader-a ---
(1, 'Alice', 'alice@example.com')
(2, 'Bob', 'bob@example.com')
(3, 'Charlie', 'charlie@example.com')
Read 3 rows

--- reader-b ---
(1, 'Alice', 'alice@example.com')
(2, 'Bob', 'bob@example.com')
(3, 'Charlie', 'charlie@example.com')
Read 3 rows

Both readers see all three rows. The volume acts as durable shared storage that any number of sandboxes can mount.


Cleanup

Terminate the readers and — if you’re done — delete the volume:

reader_a.terminate()
reader_b.terminate()

# Delete the volume when you no longer need the data.
vol = Volume(name=VOLUME_NAME)
vol.delete()

How it fits together

  Writer                  Volume                   Reader A        Reader B
    │                       │                         │               │
    │  sqlite3.connect()    │                         │               │
    │  CREATE TABLE users   │                         │               │
    │  INSERT 3 rows        │                         │               │
    │  conn.commit()        │                         │               │
    │──────────────────────▸│  app.db                 │               │
    │                       │                         │               │
    │  terminate()          │                         │               │
    ╳                       │                         │               │
                            │                         │               │
                            │  volumes=[...]          │               │
                            │◂────────────────────────│               │
                            │  volumes=[...]                        │
                            │◂────────────────────────────────────────│
                            │                         │               │
                            │  SELECT * FROM users    │               │
                            │◂────────────────────────│               │
                            │  3 rows ───────────────▸│               │
                            │                         │               │
                            │  SELECT * FROM users                    │
                            │◂────────────────────────────────────────│
                            │  3 rows ───────────────────────────────▸│
                            │                         │               │

When to use this pattern

Good fit:

  • Experiment metadata, run configs, or coordination state under ~1 GB
  • Data that one process writes and many read (write-once, read-many)
  • Situations where standing up Postgres or Redis is overkill

Not a good fit:

  • Concurrent writers — SQLite uses file-level locking, so only one writer can hold the lock at a time. For multi-writer workloads, use Postgres.
  • Datasets larger than a few GB — SQLite performs well up to moderate sizes, but a dedicated database handles large-scale analytics better.
  • Low-latency reads under load — volume I/O goes through ChalkFS (FUSE), which adds a small overhead compared to local disk. If you need sub-millisecond reads, consider Redis or an in-memory cache.