use db gives you SQL — real SQL, no ORM, no query builder. Connections
are pooled handles, results come back as the frames you learned in
chapter 10, and failures are the error values you learned in chapter 7.
Nothing in this chapter is a new concept; it's the existing concepts
meeting a database.
1 2 3 4 5 6 7 8 9 10
use db
conn = connect['sqlite://:memory:']
exec[conn, 'CREATE TABLE trades (id INTEGER PRIMARY KEY, symbol TEXT, qty INTEGER, price REAL)']
exec[conn, 'INSERT INTO trades VALUES (1, ?, ?, ?)', ['AAPL', 100, 191.5]]
exec[conn, 'INSERT INTO trades VALUES (2, ?, ?, ?)', ['MSFT', 50, 415.2]]
exec[conn, 'INSERT INTO trades VALUES (3, ?, ?, ?)', ['AAPL', 40, 193.1]]
rows = query[conn, 'SELECT symbol, qty FROM trades WHERE qty >= 50 ORDER BY id']
log[rows]expected output — from the book, verified by crow test --book
symbol qty
str int
------ ---
AAPL 100
MSFT 50
[2 rows x 2 cols]Three things to notice:
sqlite://:memory: here;
a file path or postgres://user:pass@host/dbname in production. Your
code doesn't change.? placeholders bind values from an array — never interpolate user
input into SQL text. The bind array is positional open data, one of
the few places a mixed-type array literal is exactly right.>=, quotes, and
% wildcards need no escaping — and it's >= in there, not gte;
the database speaks its own language.query returns a frame — everything from chapter 10 (filter, sort,
columns, sum) applies to results directly. exec returns the affected
row count:
1 2 3 4 5 6 7 8 9
use db
conn = connect['sqlite://:memory:']
exec[conn, 'CREATE TABLE trades (id INTEGER PRIMARY KEY, symbol TEXT, qty INTEGER)']
exec[conn, 'INSERT INTO trades VALUES (1, ?, ?)', ['AAPL', 100]]
exec[conn, 'INSERT INTO trades VALUES (2, ?, ?)', ['MSFT', 50]]
n = exec[conn, 'UPDATE trades SET qty = qty + 10 WHERE symbol = ?', ['AAPL']]
log[n]expected output — from the book, verified by crow test --book
1A bad query doesn't crash and doesn't throw — it hands you chapter 7:
1 2 3 4 5 6 7
use db
conn = connect['sqlite://:memory:']
bad = query[conn, 'SELECT nope FROM missing_table']
log[bad.ok]
log[bad.reason]
log[bad.kind]expected output — from the book, verified by crow test --book
false
no such table: missing_table
dbCheck .ok at this boundary (databases are exactly the kind of boundary
chapter 7 meant), branch on .kind eq 'db' if you handle database
failures specially, and let success flow.
transact[conn, fn] runs a named function inside a transaction — commit
if it completes, roll back everything if it fails. All-or-nothing
transfers, the classic:
1 2 3 4 5 6 7 8 9 10 11 12 13
use db
conn = connect['sqlite://:memory:']
exec[conn, 'CREATE TABLE accounts (name TEXT PRIMARY KEY, balance INTEGER)']
exec[conn, 'INSERT INTO accounts VALUES (?, ?)', ['alice', 100]]
exec[conn, 'INSERT INTO accounts VALUES (?, ?)', ['bob', 50]]
fn transfer[tx]
exec[tx, 'UPDATE accounts SET balance = balance - 30 WHERE name = ?', ['alice']]
exec[tx, 'UPDATE accounts SET balance = balance + 30 WHERE name = ?', ['bob']]
transact[conn, transfer]
log[query[conn, 'SELECT * FROM accounts ORDER BY name']]expected output — from the book, verified by crow test --book
name balance
str int
----- -------
alice 70
bob 80
[2 rows x 2 cols]The function receives a transaction handle tx and uses the same exec
on it — passed by name, like every callback in Crow.
That's the whole database API: connect, query, exec, insert,
transact, migrate.
There's a class of database bug that no amount of testing on your laptop
will find, because it only exists when two copies of your program run at
once. Here's the classic — enforcing "at most one active trade per symbol"
with an if:
1 2 3 4 5 6 7 8 9 10 11 12
use db
conn = connect['sqlite://:memory:']
exec[conn, 'CREATE TABLE trades (symbol TEXT, qty INT, status TEXT)']
sym = 'AAPL'
open_trades = query[conn, 'SELECT symbol FROM trades WHERE symbol = ? AND status = ?', [sym, 'active']]
if len[open_trades] eq 0
exec[conn, 'INSERT INTO trades (symbol, qty, status) VALUES (?, ?, ?)', [sym, 100, 'active']]
after = query[conn, 'SELECT symbol FROM trades']
log[len[after]]expected output — from the book, verified by crow test --book
1Runs perfectly. Ships. Then one day two copies run at the same moment:
both query, both see zero rows, both insert — two active trades, and the
if never lied, it just couldn't hold between two programs.
Point crow check at your development database and it reads both sides
of the story — your code's assumptions and the schema's actual guarantees:
crow check trading.co --db 'sqlite://dev.db'
trading.co:7-9 — two programs can pass this check at the same time
what can happen:
1. program A runs the query (line 7) — the check passes
2. program B runs the same query — the check passes too
3. A writes (line 9). 4. B writes.
5. the rule the check was protecting is now broken
the database has no rule saying 'at most one active trades row per symbol',
so this if is the only guard — and an if cannot hold between two programs.
fixes, safest first:
[1] let the database enforce it — generates migrations/one_active_per_symbol.sql:
CREATE UNIQUE INDEX one_active_per_symbol ON trades(symbol) WHERE status = 'active';
crow fix trading.co --hazard 1 --option 1 applies the first option:
writes the migration, applies it to the dev database, and re-checks to
confirm the hazard is gone. After that, the losing program's insert comes
back as an ordinary error value with kind = 'conflict' — loud, at the
moment it matters, instead of a silent duplicate.
The catalog goes well beyond this one shape — lost updates, one-way status
flags, single-row reads on non-unique columns, un-transacted write pairs,
plus schema verification (column typos get did-you-mean suggestions). Put
db = 'sqlite://dev.db' in your crowfile and every crow check — and the
editor, through crow lsp — runs them. The full reference is
docs/crow-check.md.
Next: putting a live web page in front of it.
An array bound as a parameter expands to a list, so WHERE id IN ?
takes [[1, 3]] and never needs the ids pasted into the query text:
1 2 3 4 5 6 7 8 9
use db
conn = db.connect['sqlite://:memory:']
exec[conn, 'CREATE TABLE t (id INTEGER, name TEXT)']
exec[conn, 'INSERT INTO t VALUES (1, ?)', ['ann']]
exec[conn, 'INSERT INTO t VALUES (2, ?)', ['bob']]
exec[conn, 'INSERT INTO t VALUES (3, ?)', ['cat']]
picked = query[conn, 'SELECT name FROM t WHERE id IN ? ORDER BY id', [[1, 3]]]
log[picked.name]expected output — from the book, verified by crow test --book
['ann', 'cat']The CREATE TABLE statements your program runs are its schema. Each
literal one it hands a connection declares that table, and opening the
connection checks the tables the database already has against them:
1 2 3 4
use db
book = connect['sqlite://board.db']
exec[book, 'CREATE TABLE IF NOT EXISTS cards (id INTEGER PRIMARY KEY, title TEXT, due TEXT)']Add due to that statement after board.db was made without it, and
CREATE TABLE IF NOT EXISTS alone would do nothing: the first query that
reads due would fail. Run from its source (crow run, crow run -i,
crow test), the program reconciles instead: the connection opens,
finds cards without due, and adds it, under a lock so two processes
starting at once add it once. What it cannot add without guessing — a
NOT NULL column with no default on a table that has rows, a column of
another kind than the database holds — stops the program with the SQL
that would make it.
A program crow build makes verifies: it changes nothing, reads the
catalog, and stops at connect if a table lacks a column the program
declares, printing the statement that would mend it — the database in
production is someone else's to change. crow db diff prints every such
statement as a migration to review, with what reconciling would not make
commented out:
$ crow db diff app.co
-- sqlite://board.db (book)
-- table 'cards' lacks column 'due'
ALTER TABLE "cards" ADD COLUMN due TEXT;
schema = 'verify' in the crowfile (or 'reconcile', or 'trust',
which checks nothing and says so at every start) changes the mode for
both; CROW_SCHEMA=trust changes it for one run. A table your program
does not make is not its to check.
A query's result is a frame, and a frame's cells are typed by the
database: an integer column reads as an int, a numeric one as a float.
The interpreter sees that at run time. The compiled program cannot, so
crow build and crow run ask the databases your program connects to
what each literal query returns — a prepared statement on Postgres, a
DESCRIBE on ClickHouse — and type the cells the same way. A ?
placeholder and an interpolated span stand in as NULL for the question.
If a database cannot be reached while building, the build says so once
and that query's cells read as text, which is what compiled frames always
did before. CROW_NO_DESCRIBE=1 skips the question entirely.
Next: the web layer, where these frames become pages.