Ploy
Ploy
Features

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:

ploy.yaml
kind: dynamic
build: pnpm build
out: dist
db:
  DB: default

The 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:

env.d.ts
import type { Database } from "@meetploy/types";

export interface Env {
	DB: Database;
}

Basic Example

src/index.ts
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/
migrations/
├── 001_create_users.sql
└── 20260403171410_add_posts.sql

Nested migration folders are also supported. Any .sql file in the folder is executed, and sibling files like snapshot.json are ignored:

migrations/
migrations/
└── 20260403171410_init/
    ├── migration.sql
    └── snapshot.json

If your project uses multiple DB bindings, scope migrations by binding name:

migrations/
migrations/
├── DB/
│   └── 20260403171410_init/
│       └── migration.sql
└── ANALYTICS_DB/
    └── 001_create_events.sql

Production 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); // 1

exec() - 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 executed

withSession() - 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 | null

Always 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:

LimitDefault
SQL text per statement100 KB
Bound parameters per statement100
Statements per batch()100
SQL text per exec() script1 MB
Statements per exec() script1000

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

  • Workers - Learn about Ploy workers
  • Queues - Add background job processing

How is this guide?

Last updated on