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";
const db = new MinnowDatabase(await IndexedDbBlockStore.open({ name: "shop" }));Create tables
DDL is SQL like everything else. PRIMARY KEY declares the table's unique key — the column that
UPDATE, DELETE, and upserts address rows through.
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 ? 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, "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 (?, ?, 'completed', ?, ?)
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 } = 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 = ?
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's names and types, which is what a grid needs to render without inspecting
values.
See what it decided to do
console.log(await db.explain("SELECT * FROM orders WHERE order_id = 1001"));The plan shows which access path the optimizer chose, where predicates were pushed, and which joins were reordered. Query plans reads one line by line.
Try it without installing anything
The playground 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.
- The typed client — the optional builder, if you would rather write queries that infer their own row types than write SQL strings.
- Transactions — atomic multi-statement writes and stable reads.
- Workers — moving the engine off the main thread.