Databases
Add SQLite databases to your workers with Ploy.
Databases
Ploy supports SQLite databases for workers and applies project migrations automatically. Each project gets its own isolated database that persists across deployments.
Configuration
Add a database binding in your ploy.yaml:
kind: dynamic
build: pnpm build
out: dist
db:
DB: defaultThe key (DB) is the binding name available in your worker's env. The value (default) is the database identifier.
Run ploy types to generate TypeScript types:
import type { Database } from "@meetploy/types";
export interface Env {
DB: Database;
}Basic Example
export default {
async fetch(request, env) {
// Assumes the users table exists from your migrations
await env.DB.prepare("INSERT INTO users (name) VALUES (?)")
.bind("Alice")
.run();
// Query
const { results } = await env.DB.prepare("SELECT * FROM users").all();
return Response.json({ users: results });
},
} satisfies Ploy;Migrations
Create project migrations in migrations/. Ploy runs them in filename order and tracks applied files in ploy_internal_db_migrations inside the same database.
For a single DB binding, you can keep migrations at the root:
migrations/
├── 001_create_users.sql
└── 20260403171410_add_posts.sqlNested migration folders are also supported. Any .sql file in the folder is executed, and sibling files like snapshot.json are ignored:
migrations/
└── 20260403171410_init/
├── migration.sql
└── snapshot.jsonIf your project uses multiple DB bindings, scope migrations by binding name:
migrations/
├── DB/
│ └── 20260403171410_init/
│ └── migration.sql
└── ANALYTICS_DB/
└── 001_create_events.sqlProduction deploys on the default branch apply pending migrations before the deployment is uploaded, and the applied files are logged in the build output. During local development, ploy dev applies project migrations on startup.
Query Methods
all() - Get All Rows
const { results } = await env.DB.prepare("SELECT * FROM users").all();first() - Get First Row
const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?")
.bind(1)
.first();run() - Execute Statement
const result = await env.DB.prepare("INSERT INTO users (name) VALUES (?)")
.bind("Bob")
.run();
console.log(result.meta.changes); // 1
console.log(result.meta.last_row_id); // 1exec() - Run Raw SQL
const result = await env.DB.exec(`
CREATE TABLE IF NOT EXISTS posts (id INTEGER PRIMARY KEY, title TEXT);
CREATE INDEX IF NOT EXISTS idx_title ON posts(title);
`);
console.log(result.count); // number of statements executedwithSession() - Session Compatibility
Ploy exposes withSession() so code that expects a session object can keep using prepare() and batch() unchanged.
const session = env.DB.withSession("first-primary");
const user = await session
.prepare("SELECT * FROM users WHERE id = ?")
.bind(1)
.first<User>();Result Metadata
Every query resolves to a result object with a meta field describing what the
statement did:
const result = await env.DB.prepare("UPDATE users SET name = ? WHERE id = ?")
.bind("Bob", 1)
.run();
result.meta;
// {
// duration: 0.42, // milliseconds spent executing the statement
// rows_read: 0, // rows the statement read
// rows_written: 1, // rows the statement wrote
// changes: 1, // rows the statement affected
// last_row_id: 1, // rowid of the last inserted row, 0 if none
// changed_db: true // whether the statement modified the database
// }Use changes when you need the number of rows an INSERT, UPDATE, or
DELETE affected — for example to confirm that a conditional update actually
matched a row:
const { meta } = await env.DB.prepare(
"UPDATE credits SET claimed = 1 WHERE id = ? AND claimed = 0",
)
.bind(grantId)
.run();
if (meta.changes === 0) {
// Already claimed by another request — do not grant it twice.
}rows_read and rows_written are usage counters that drive billing, so treat
them as metering data rather than as an affected-row count.
TypeScript Support
Add generics for type-safe queries:
interface User {
id: number;
name: string;
}
const users = await env.DB.prepare("SELECT * FROM users").all<User>();
// users.results is User[]
const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?")
.bind(1)
.first<User>();
// user is User | nullAlways use prepared statements with .bind() to prevent SQL injection.
Limits
Every statement is checked before it runs, both in production and in ploy dev, so oversized queries fail the same way locally as they do once deployed:
| Limit | Default |
|---|---|
| SQL text per statement | 100 KB |
| Bound parameters per statement | 100 |
Statements per batch() | 100 |
SQL text per exec() script | 1 MB |
Statements per exec() script | 1000 |
Exceeding a limit rejects the request before it reaches SQLite — a batch() that breaks a limit applies none of its statements. The rejection is thrown in your worker with the reason attached, for example DB batch failed: Statement with 301 bound params exceeds DB_MAX_SQL_BOUND_PARAMS (100), so the offending call is identifiable from the error alone.
exec() gets its own, roomier caps because migrations run as a single script holding many statements. Each statement inside the script is still held to the per-statement limits, and migrations are checked during ploy dev too, so an oversized migration fails locally rather than at deploy time.
Self-hosted deployments can raise or lower each cap with the DB_MAX_SQL_LENGTH_BYTES, DB_MAX_SQL_BOUND_PARAMS, DB_MAX_BATCH_STATEMENTS, DB_MAX_EXEC_LENGTH_BYTES, and DB_MAX_EXEC_STATEMENTS environment variables. Set the same values for the emulator to mirror a tuned deployment during development.
Next Steps
How is this guide?
Last updated on