Your first query
Create a table, write rows, and read them back — all in SQL.
Everything below runs in a browser tab. Nothing is sent anywhere.
Open a database
import { MinnowDatabase } from "@minnowdb/core";
import { IndexedDbBlockStore } from "@minnowdb/core/storage/indexeddb";
const db = new MinnowDatabase(await IndexedDbBlockStore.open({ name: "shop" }));Create tables
DDL is SQL like everything else. PRIMARY KEY declares the table's row-addressing unique key —
the column that UPDATE, DELETE, and upserts address rows through. One inline UNIQUE column
can serve the same role.
await db.execute(`CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
name VARCHAR(80) NOT NULL,
city VARCHAR(80),
signed_up_on TIMESTAMP NOT NULL
)`);
await db.execute(`CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
status VARCHAR(20) NOT NULL,
total DOUBLE PRECISION NOT NULL,
placed_at TIMESTAMP NOT NULL
)`);Write rows
execute runs any statement. Bind values with PostgreSQL-style $1 parameters rather than
building SQL strings — the compiled plan is cached on the statement text and re-bound per
execution, so parameters are faster as well as safer.
await db.execute(
`INSERT INTO customers (customer_id, name, city, signed_up_on)
VALUES ($1, $2, $3, $4)`,
[1, "Ada Lovelace", "London", new Date("2024-03-02")],
);
const inserted = await db.execute(
`INSERT INTO orders (order_id, customer_id, status, total, placed_at)
VALUES ($1, $2, 'completed', $3, $4)
RETURNING order_id, total`,
[1001, 1, 24.5, new Date("2025-11-14")],
);
// inserted.kind === "insert", inserted.returnedRows === [{ order_id: 1001, total: 24.5 }]For loading a lot of rows at once, insertBatch takes them
columnar and skips the parser entirely.
Query
const { rows, columns, columnDomains } = await db.query(
`SELECT c.name, COUNT(*) AS orders, ROUND(SUM(o.total), 2) AS revenue
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status = $1
GROUP BY c.name
ORDER BY revenue DESC
LIMIT 10`,
{ params: ["completed"] },
);rows are plain objects — numbers are numbers, TIMESTAMP columns come back as Date. columns
carries the result names; positionally aligned columnDomains carries logical types such as
JSON/JSONB, UUID, exact numeric, and DATE. A grid can render headers without inspecting values,
and a generic consumer can parse JSON text without knowing the query in advance.
See what it decided to do
console.log(await db.explain("SELECT * FROM orders WHERE order_id = 1001"));The plan shows how Minnow will read the data, where filters run, and which joins were reordered. Query plans reads one line by line.
Try it without installing anything
The console on the home page is this, already set up: a generated retailer's database of around 590,000 rows, built in your browser and kept in IndexedDB. Every query on this site runs against it for real.
Where to go next
- Running SQL — the full statement API, parameters, and result shapes.
- Writing data — inserts, updates, deletes, upserts,
RETURNING. - Schema & migrations — declare those tables in TypeScript instead, and evolve them safely as the application changes.
- Client adapters — use Kysely instead of SQL strings.
- Transactions — atomic multi-statement writes and stable reads.
- Workers — moving the engine off the main thread.