A stateful API: wiring a private database to a function
A function is stateless, and that's where half of all projects stop. Here's the other route in full: a database that sleeps when nobody queries it, a schema laid down properly, and stock kept as a ledger instead of a counter two writes can clobber.
A function is stateless. That's what makes it billable by the second and effortless to scale, and it's also what stops half of all projects twenty minutes in, at the exact moment something needs to be remembered. The usual next move is expensive: a managed Postgres at fifteen euros a month for an API that serves thirty requests a day.
Here's the other route, end to end: a private database that sleeps when nobody is querying it, a schema laid down properly, and an API that keeps its books straight. Four calls and two functions.
The example is a shop inventory: list the catalogue, move stock in and out. Small enough to fit in an article, real enough to run into the one design decision that actually matters here.
Creating the database
curl -X POST -H "Authorization: Bearer $JWT" \
-d '{"name": "inventory"}' \
https://zerolith.io/api/databasesThat's the whole thing. With no parameters you get the default storage tier, 500 MB, and the
medium preset for the instance (256 MiB of memory, 0.25 vCPU). Tiers go up to 10 GB in 500 MB
steps, and can be raised at any time.
What the response doesn't contain matters just as much: the auth token isn't in it, and it's never displayed anywhere, ever. Not in the UI, not through the API, not in logs. It's mounted into your pods from a write-only object. You never handle it, so you can't leak it.
Technically it's networked SQLite, replicated continuously to object storage. Practically, it lives in your isolated namespace, is never publicly exposed, and is reachable only by your own functions.
The schema: a migrator function
The database server cannot call your functions. That's a deliberate design constraint: nothing on the platform runs your code unless you asked it to. So migrations are explicit. You designate a function as the migrator, and you trigger it.
import os
import libsql_client
DDL = [
"""CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL DEFAULT 0
)""",
# Stock is NOT a column here: see the next section.
"""CREATE TABLE IF NOT EXISTS movements (
id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id),
delta INTEGER NOT NULL,
reason TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)""",
"CREATE INDEX IF NOT EXISTS movements_product ON movements(product_id)",
]
def handler(request):
db = libsql_client.create_client_sync(
url=os.environ["DATABASE_URL"],
auth_token=os.environ["DATABASE_AUTH_TOKEN"],
)
for statement in DDL:
db.execute(statement)
db.close()
return {"migrated": len(DDL)}libsql_client already ships in the Python runtime image, nothing to install. Deploy it with the
migrate kind — the kind built for exactly this, and not a convenience detail:
curl -X POST -H "Authorization: Bearer $JWT" \
-d '{"name": "migrate", "kind": "migrate", "code": "<the code above>"}' \
https://zerolith.io/api/functionsA migrate function has no service, no route, no hostname, and no pod between runs. It is
therefore private by construction rather than by a flag: there is nothing for the gateway to admit
a request on, and public: true is refused rather than ignored. You also stop paying for the
internal gateway sidecar that would stand by for an HTTP server whose only caller is the platform
itself. The code does not change: same handler, same request argument — it carries
method: "RUN" and nothing else.
The kind is set at create and immutable afterwards. A migrator already deployed as http keeps
working; to move it over, delete it and recreate it under the same name.
That leaves designating it and firing it:
# Designating the migrator also attaches it, so it receives the injection
curl -X PUT -H "Authorization: Bearer $JWT" \
-d '{"function_id": "<migrate-function-id>"}' \
https://zerolith.io/api/databases/<database-id>/migrator
# Run the migration once, on demand
curl -X POST -H "Authorization: Bearer $JWT" \
https://zerolith.io/api/databases/<database-id>/migrateThe CREATE TABLE IF NOT EXISTS isn't laziness. The migration is triggered by hand, so it has to be
replayable with no consequence: that's what makes it safe to run again when you no longer remember
whether it went through.
The decision that matters: a ledger, not a counter
This is the one real design decision in the example, and it isn't serverless-specific. It's just far more visible here.
The tempting shape is a stock column you increment. The problem isn't performance, it's that two
concurrent writes overwrite each other: two pods woken in parallel both read 10, both add 5, and 15
gets written instead of 20. On a platform that starts instances when traffic rises, that isn't a
textbook edge case, it's the default behaviour.
A ledger of movements doesn't have the problem, because every write is an insert and never an update. Stock becomes the sum of the ledger, history comes free (where each unit came from, when, why), and there's nothing left to overwrite. In exchange you aggregate on read, which, indexed and at this scale, doesn't measure.
It's the same shape the platform uses for your own account credit, incidentally: an append-only ledger, never a rewritten balance column.
import os
import libsql_client
STOCK = """SELECT p.id, p.sku, p.name, p.price_cents,
COALESCE(SUM(m.delta), 0) AS stock
FROM products p
LEFT JOIN movements m ON m.product_id = p.id
GROUP BY p.id
ORDER BY p.name"""
def db():
return libsql_client.create_client_sync(
url=os.environ["DATABASE_URL"],
auth_token=os.environ["DATABASE_AUTH_TOKEN"],
)
def handler(request):
client = db()
try:
if request.method == "POST":
body = request.json() or {}
# A movement, not an update: +5 on delivery, -1 on a sale.
client.execute(
"INSERT INTO movements (product_id, delta, reason) VALUES (?, ?, ?)",
[body["product_id"], body["delta"], body.get("reason", "manual")],
)
return 201, {"ok": True}
rows = client.execute(STOCK).rows
return {"products": [dict(zip(r.keys(), r)) for r in rows]}
finally:
client.close()One function serves both the read and the write, off request.method and request.json(). The full
handler contract is in the documentation.
Attach, then call
curl -X POST -H "Authorization: Bearer $JWT" \
-d '{"function_id": "<inventory-api-function-id>"}' \
https://zerolith.io/api/databases/<database-id>/attachAttaching injects DATABASE_URL and DATABASE_AUTH_TOKEN into the pod and rolls a new revision.
From then on the function is called like any other, with your API key:
curl -H "Authorization: Bearer $ZEROLITH_KEY" "https://<function-url>"
curl -X POST -H "Authorization: Bearer $ZEROLITH_KEY" \
-d '{"product_id": 1, "delta": 5, "reason": "delivery"}' \
"https://<function-url>"Two details that save half an hour of confusion:
- An attached database cannot be deleted (409). Detach it first. That's deliberate: deletion
destroys the data, and a
DELETEon the wrong resource shouldn't be enough to do it. - The storage tier is a hard ceiling. Past it, writes fail. It doesn't quietly grow to bill you more, which would be more convenient and considerably less honest.
What it costs
Two lines on the bill, and they're not the same kind of thing.
Storage is billed on the tier you hold, not the bytes you use, at €0.40/GB-month, settled daily. So the default 500 MB tier is €0.20/month, whether the database is full or empty, asleep or awake. That's what you pay for the data still being there tomorrow.
Compute follows exactly the same prices as a function of the same size. The medium preset reserves
0.25 GiB and 0.25 vCPU:
memory 0.25 GiB × €0.000004 = €0.000001 /s
cpu 0.25 vCPU × €0.0000125 = €0.000003125 /s
────────────────
€0.000004125 /sAnd here's the same trap as with a cron, the one serverless write-ups never mention. A database doesn't fall asleep when your query returns. The default idle window is 10 minutes, tunable from 6 seconds to 1 hour, so one isolated query keeps the instance up for ten:
600 s × €0.000004125 ≈ €0.0025 (a quarter of a cent)
That number is good news badly presented: a ten-minute window means thirty requests in the same half hour don't cost thirty wake-ups, they cost one or two. A short window saves money on widely spaced traffic, a long one on bursty traffic. It's switchable at any time and the data never moves. And if first-call latency has to be constant, always-on keeps one instance up, at the corresponding compute cost, continuously this time.
So for an inventory queried a few times a day you're looking at a few tens of cents a month,
dominated by storage rather than by compute. Check it against your own account:
GET /api/usage/summary breaks down the current month.
The deadline to know about before you put data in
There's exactly one destructive deadline on the whole platform, and this is it: if your balance hits zero the database is suspended, and after 7 days its storage is purged for good. The clock starts at the suspension, and a top-up clears it.
It's a deliberate choice, unpaid storage can't be kept forever, but it's the kind of rule an article has a duty to state before encouraging you to put anything in there. It comes with its counterweight, an export that actually works:
# Kick off a SQL dump, in the background
curl -X POST -H "Authorization: Bearer $JWT" \
https://zerolith.io/api/databases/<database-id>/export
# Then read its status and pick up the download link (valid for 24 h)
curl -H "Authorization: Bearer $JWT" \
https://zerolith.io/api/databases/<database-id>/exportsThe dump is a .sql.gz file, and reading it back needs nothing from Zerolith:
gunzip -c dump.sql.gz | sqlite3 restored.db
Getting your data out is neither a paid add-on nor a planned feature.
Where to go next
The same exercise without persistent state, with a scheduled job instead, is in the other article: one function, one cron expression, half a cent a month.
The database page explains the isolation model and what happens while it sleeps; the database section of the docs keeps the reference current (injected variables, tiers, migrations, exports). Unit prices are on the pricing page.
Try it on your own account
Signing up comes with credit, enough to deploy, schedule and measure everything above, without getting a card out.
Get started »