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.
Use a database when your app needs relational data that outlives a request: users, orders, content, anything you would otherwise put in Postgres or MySQL. For simple key-value data, State is lighter; for files, use File Storage.
This page covers the binding and its runtime API. For framework-specific setup, see the Next.js database guide and the Ploy Start database guide, which adds Drizzle ORM.
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.
Ploy reads migrations from your repository at the deployed commit, after checking that the commit is on the default branch. Commit your migration files: files that only exist in the build output (for example, generated by the build command) are not applied, and the build log lists them.
Each migration runs in a transaction together with its bookkeeping row, and Ploy records a checksum of every applied file. Applied migrations are immutable: if an applied file changes, the deploy fails. Restore the original file and add a new migration for the change.
To keep migrations somewhere else, set migrations in ploy.yaml to a path relative to the config file:
db:
DB: kr_db
migrations: ../../migrationsQuery 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
- Next.js database guide - Query the database from server components and route handlers
- Ploy Start database guide - Use Drizzle ORM with Ploy Start
- Authentication - Store users in a database binding
- Workers - Learn about Ploy workers
- Queues - Add background job processing
How is this guide?
Last updated on