
Cloudflare D1
- 283 installs
- 202 repo stars
- Updated August 4, 2026
- secondsky/claude-skills
Use cloudflare-d1 for development tasks
About
cloudflare-d1: A skill for development. This provides functionality for development workflows.
- cloudflare-d1
Cloudflare D1 by the numbers
- 283 all-time installs (skills.sh)
- +17 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #1,392 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/secondsky/claude-skills --skill cloudflare-d1Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 283 |
|---|---|
| repo stars | ★ 202 |
| Last updated | August 4, 2026 |
| Repository | secondsky/claude-skills ↗ |
What it does
Use cloudflare-d1 for development tasks
Files
Cloudflare D1 Database
Status: Production Ready ✅ | Last Verified: 2025-01-15
Table of Contents
1. What Is D1? 2. Quick Start 3. Critical Rules 4. D1 API Methods 5. Top 5 Use Cases 6. Migrations Best Practices 7. Common Patterns 8. SQLite Type Affinity 9. Top 5 Errors Prevented
---
What Is D1?
Cloudflare D1 is serverless SQLite on the edge:
- SQL database without servers
- Global distribution
- Zero cold starts
- Standard SQLite syntax
- Read replication for global performance
---
🆕 New in 2025
D1 received major updates throughout 2025:
Performance (January 2025)
- 40-60% latency reduction globally (P50 query times)
- Optimized SQLite engine for edge execution
- Reduced cold start impact for databases <100 MB
Reliability (September 2025)
- Automatic query retries: Read queries retry up to 2x on transient failures
- Transparent to application code (logged in
wrangler tail)
Scalability (April 2025)
- Read Replication (Public Beta): Deploy read replicas globally
- Up to 2x read throughput for read-heavy workloads
- Sessions API for read-write separation
Compliance (November 2025)
- Data Localization: Specify EU/US jurisdiction for GDPR/data sovereignty
- Configure via
--jurisdictionflag or wrangler.jsonc
⚠️ Breaking Change (February 10, 2025)
- Free tier hard limits enforced: 10 DBs, 500 MB each, 50 queries/invocation
- Exceeding limits = 429 errors (previously warnings only)
- Action: Review usage with
wrangler d1 listand upgrade if needed
Full details: Load references/2025-features.md
---
Quick Start (5 Minutes)
1. Create Database
bunx wrangler d1 create my-databaseSave the database_id from output!
2. Configure Binding
Add to wrangler.jsonc:
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-11",
"d1_databases": [
{
"binding": "DB", // env.DB
"database_name": "my-database",
"database_id": "<UUID>",
"preview_database_id": "local-db"
}
]
}3. Create Migration
bunx wrangler d1 migrations create my-database create_usersEdit migrations/0001_create_users.sql:
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
PRAGMA optimize;4. Apply Migration
# Local
bunx wrangler d1 migrations apply my-database --local
# Production
bunx wrangler d1 migrations apply my-database --remote5. Query from Worker
import { Hono } from 'hono';
type Bindings = {
DB: D1Database;
};
const app = new Hono<{ Bindings: Bindings }>();
app.get('/users/:email', async (c) => {
const { results } = await c.env.DB.prepare(
'SELECT * FROM users WHERE email = ?'
)
.bind(c.req.param('email'))
.all();
return c.json(results);
});
export default app;Load `references/setup-guide.md` for complete walkthrough.
---
Critical Rules
Always Do ✅
1. Use prepared statements with .bind() (never string concatenation) 2. Create indexes for WHERE/JOIN/ORDER BY columns 3. Use migrations for schema changes (never manual SQL) 4. Batch queries for multiple operations (.batch()) 5. Run PRAGMA optimize after schema changes 6. Handle errors explicitly (try/catch) 7. Use INTEGER for timestamps (Date.now()) 8. Test locally before deploying migrations 9. Use read replicas for global read performance 10. Validate input before SQL queries
Never Do ❌
1. Never concatenate user input into SQL 2. Never commit database_id to public repos 3. Never skip migrations for schema changes 4. Never use VARCHAR (use TEXT instead) 5. Never skip indexes for filtered columns 6. Never ignore SQLite type affinity rules 7. Never use SELECT without LIMIT 8. Never run migrations without testing locally 9. Never exceed 1MB per row 10. Never use DATETIME* (use INTEGER for timestamps)
---
D1 API Methods
prepare() - Execute Queries
// Single result
const { results } = await env.DB.prepare(
'SELECT * FROM users WHERE email = ?'
)
.bind(email)
.all();
// First result only
const user = await env.DB.prepare(
'SELECT * FROM users WHERE user_id = ?'
)
.bind(userId)
.first();
// Raw results (faster)
const { results } = await env.DB.prepare(
'SELECT username FROM users'
)
.raw(); // Returns arrays instead of objectsbatch() - Multiple Queries
const results = await env.DB.batch([
env.DB.prepare('INSERT INTO users (email, username, created_at) VALUES (?, ?, ?)')
.bind('user1@example.com', 'user1', Date.now()),
env.DB.prepare('INSERT INTO users (email, username, created_at) VALUES (?, ?, ?)')
.bind('user2@example.com', 'user2', Date.now()),
env.DB.prepare('SELECT COUNT(*) as count FROM users')
]);
console.log('Users count:', results[2].results[0].count);All queries execute in single transaction (all succeed or all fail).
exec() - Run SQL String
// For migrations/setup only
await env.DB.exec(`
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY,
email TEXT NOT NULL
);
CREATE INDEX idx_email ON users(email);
`);NEVER use for queries with user input!
Load `references/query-patterns.md` for complete API reference.
---
Top 5 Use Cases
Use Case 1: User CRUD
// Create
app.post('/users', async (c) => {
const { email, username } = await c.req.json();
const { results } = await c.env.DB.prepare(
'INSERT INTO users (email, username, created_at) VALUES (?, ?, ?) RETURNING *'
)
.bind(email, username, Date.now())
.all();
return c.json(results[0]);
});
// Read
app.get('/users/:id', async (c) => {
const user = await c.env.DB.prepare(
'SELECT * FROM users WHERE user_id = ?'
)
.bind(c.req.param('id'))
.first();
if (!user) {
return c.json({ error: 'Not found' }, 404);
}
return c.json(user);
});
// Update
app.patch('/users/:id', async (c) => {
const { username } = await c.req.json();
await c.env.DB.prepare(
'UPDATE users SET username = ?, updated_at = ? WHERE user_id = ?'
)
.bind(username, Date.now(), c.req.param('id'))
.run();
return c.json({ success: true });
});
// Delete
app.delete('/users/:id', async (c) => {
await c.env.DB.prepare(
'DELETE FROM users WHERE user_id = ?'
)
.bind(c.req.param('id'))
.run();
return c.json({ success: true });
});Use Case 2: Batch Operations
app.post('/users/bulk', async (c) => {
const users = await c.req.json(); // Array of users
const statements = users.map(user =>
c.env.DB.prepare(
'INSERT INTO users (email, username, created_at) VALUES (?, ?, ?)'
).bind(user.email, user.username, Date.now())
);
const results = await c.env.DB.batch(statements);
return c.json({ inserted: results.length });
});Use Case 3: Read Replication (Global Reads)
// Configure read replica (any region)
const session = c.env.DB.withSession({
preferredRegion: 'auto' // or 'weur', 'wnam', 'enam', 'apac'
});
// Read from nearest replica
const { results } = await session.prepare(
'SELECT * FROM users WHERE email = ?'
)
.bind(email)
.all();
// Check which region served request
console.log('Served by:', results[0].served_by_region);Load `references/read-replication.md` for complete guide.
Use Case 4: Transactions with Batch
// Transfer credits between users (atomic)
const results = await c.env.DB.batch([
c.env.DB.prepare(
'UPDATE users SET credits = credits - ? WHERE user_id = ?'
).bind(amount, fromUserId),
c.env.DB.prepare(
'UPDATE users SET credits = credits + ? WHERE user_id = ?'
).bind(amount, toUserId),
c.env.DB.prepare(
'INSERT INTO transactions (from_user, to_user, amount, created_at) VALUES (?, ?, ?, ?)'
).bind(fromUserId, toUserId, amount, Date.now())
]);
// All succeed or all fail (transaction)Use Case 5: Pagination
app.get('/users', async (c) => {
const page = parseInt(c.req.query('page') || '1');
const limit = 20;
const offset = (page - 1) * limit;
const { results } = await c.env.DB.prepare(
'SELECT * FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?'
)
.bind(limit, offset)
.all();
return c.json({
users: results,
page,
limit
});
});---
Migrations Best Practices
1. Always Use Migrations
bunx wrangler d1 migrations create my-database add_users_avatar2. Make Migrations Idempotent
-- ✅ GOOD: Idempotent
CREATE TABLE IF NOT EXISTS users (...);
CREATE INDEX IF NOT EXISTS idx_email ON users(email);
DROP TABLE IF EXISTS old_table;
-- ❌ BAD: Fails on re-run
CREATE TABLE users (...);
CREATE INDEX idx_email ON users(email);3. Test Locally First
bunx wrangler d1 migrations apply my-database --local
bunx wrangler d1 execute my-database --local --command "SELECT * FROM users"4. Add PRAGMA optimize
-- End of migration
PRAGMA optimize;Load `templates/schema-example.sql` for complete schema template.
---
When to Load References
Load references/setup-guide.md when:
- First-time D1 setup
- Creating first database
- Configuring bindings
- Applying first migration
Load references/query-patterns.md when:
- Need complete API reference
- Complex query patterns
- Batch operations
- Error handling
Load references/read-replication.md when:
- Setting up global reads
- Need low latency worldwide
- Understanding Sessions API
- Sequential consistency required
Load references/best-practices.md when:
- Optimizing query performance
- Schema design decisions
- Index strategies
- Production deployment checklist
Load references/limits.md when:
- Encountering 429 errors or quota warnings
- Planning capacity for free vs paid tiers
- Understanding database/query limits
- Migrating to paid plan
Load references/metrics-analytics.md when:
- Investigating performance issues
- Setting up monitoring and alerts
- Using
wrangler d1 insightscommand - Analyzing query efficiency
Load references/2025-features.md when:
- Upgrading from v2.x to v3.x
- Enabling new features (auto-retry, jurisdiction, replication)
- Understanding breaking changes (Feb 10, 2025 enforcement)
- Migrating before deadlines
Interactive Tools
Agents (Autonomous diagnostics):
- `agents/d1-debugger.md`: 9-phase diagnostic (config, migrations, queries, bindings, errors, limits, performance, Time Travel)
- `agents/d1-query-optimizer.md`: Performance analysis (slow queries, missing indexes, optimization recommendations)
Commands (Interactive wizards):
- `commands/cloudflare-d1:setup.md`: Interactive first-time setup wizard
- `commands/d1-create-migration.md`: Guided migration creation with validation
---
Using Bundled Resources
References (references/)
- setup-guide.md - Complete setup walkthrough
- query-patterns.md - Complete API reference with examples
- read-replication.md - Global read replicas setup
- best-practices.md - Performance and optimization
Templates (templates/)
- schema-example.sql - Complete schema with indexes
- d1-worker-queries.ts - All query patterns in Workers
- cloudflare-d1:setup-migration.sh - Complete setup script
---
Common Patterns
Error Handling
try {
const { results } = await env.DB.prepare(
'SELECT * FROM users WHERE email = ?'
)
.bind(email)
.all();
return c.json(results);
} catch (error) {
console.error('D1 Error:', error);
return c.json({ error: 'Database error' }, 500);
}Raw Mode (Performance)
// Returns arrays instead of objects (faster)
const { results } = await env.DB.prepare(
'SELECT user_id, email FROM users'
)
.raw();
// results = [[1, 'user1@example.com'], [2, 'user2@example.com']]COUNT Queries
const count = await env.DB.prepare(
'SELECT COUNT(*) as count FROM users'
)
.first('count'); // Get single column value
console.log('Total users:', count);---
SQLite Type Affinity
D1 uses SQLite type affinity:
| Declared Type | Affinity |
|---|---|
| INTEGER, INT | INTEGER |
| TEXT, VARCHAR, CHAR | TEXT |
| REAL, FLOAT, DOUBLE | REAL |
| BLOB | BLOB |
| (no type) | BLOB |
Best practices:
- Use
INTEGERfor numbers - Use
TEXTfor strings (not VARCHAR) - Use
INTEGERfor timestamps (Date.now()) - Use
BLOBfor binary data
---
Top 5 Errors Prevented
1. SQL Injection: Use .bind(), never string concatenation 2. Missing Indexes: Create indexes for WHERE/JOIN columns 3. Migration Failures: Test locally first 4. Type Confusion: Use INTEGER for timestamps 5. Batch Size: Limit batch to <500 statements
Load `references/best-practices.md` for complete error prevention.
---
Secure Installation
When installing D1 driver packages, follow supply chain security best practices:
- Block post-install scripts —
npm config set ignore-scripts true(or Bun: disabled by default) - Cooldown period — Wait 7 days for new package versions to be vetted by the community
- Audit before installing — Run
socket package score npm <pkg>or usesocket npm install <pkg>to check packages
Load the dependency-upgrade skill for full security configuration including Socket CLI integration, cooldown setup, lockfile validation, and CI enforcement.
Official Documentation
- D1 Overview: https://developers.cloudflare.com/d1/
- Get Started: https://developers.cloudflare.com/d1/get-started/
- Client API: https://developers.cloudflare.com/d1/build-with-d1/d1-client-api/
- Read Replication: https://developers.cloudflare.com/d1/reference/read-replication/
---
Questions? Issues?
1. Check references/setup-guide.md for setup 2. Review references/query-patterns.md for API reference 3. See references/read-replication.md for global reads 4. Load references/best-practices.md for optimization
D1 2025 Features and Updates
Purpose: Comprehensive guide to all 2025 D1 updates, new features, and breaking changes
Last Updated: 2025-01-15
---
Table of Contents
1. Overview 2. Q1 2025: Performance & Reliability 3. Q2 2025: Read Replication 4. Q3 2025: Automatic Query Retries 5. Q4 2025: Data Localization 6. Migration Checklist 7. Breaking Changes Summary
---
Overview
D1 received major updates throughout 2025, including:
- 40-60% performance improvement (January 2025)
- Read replication for global low-latency reads (April 2025)
- Automatic query retries for reliability (September 2025)
- Data localization for compliance (November 2025)
- Free tier enforcement (February 10, 2025 - BREAKING CHANGE)
Impact: All D1 users benefit from performance improvements. Paid tier users can enable advanced features (read replication, data localization).
---
Q1 2025: Performance & Reliability
January 2025: Global Performance Optimization
Status: Generally Available (All users)
Improvements:
- 40-60% reduction in P50 query latency globally
- Optimized SQLite engine for edge execution
- Reduced cold start impact for databases <100 MB
- Lower network round-trip times
Before vs After (typical query):
Before (December 2024):
- P50: 35ms
- P95: 180ms
After (January 2025):
- P50: 15ms (-57%)
- P95: 85ms (-53%)Action Required: None (automatic for all databases)
Verification:
# Compare metrics before/after January 15, 2025
wrangler d1 insights <database-name> --from "2024-12-01" --to "2025-01-31"Full metrics guide: references/metrics-analytics.md
---
February 2025: PRAGMA optimize Support
Status: Generally Available
Feature: D1 now supports PRAGMA optimize for query planner improvements
What It Does:
- Analyzes table statistics
- Updates query planner statistics
- Improves query performance for complex WHERE clauses
When to Use:
-- After bulk inserts (>10,000 rows)
INSERT INTO users ...; -- (many rows)
PRAGMA optimize;
-- After creating/dropping indexes
CREATE INDEX idx_users_email ON users(email);
PRAGMA optimize;
-- Weekly maintenance (via Cron Trigger)
PRAGMA optimize;Example Worker (weekly optimization):
export default {
async scheduled(event: ScheduledEvent, env: Env) {
// Weekly database optimization
await env.DB.prepare('PRAGMA optimize').run();
console.log('Database optimized:', new Date().toISOString());
}
};Configure Cron Trigger (wrangler.jsonc):
{
"triggers": {
"crons": ["0 2 * * 0"] // 2 AM every Sunday
}
}Performance Impact: 5-20% query performance improvement for complex queries
---
February 10, 2025: Free Tier Enforcement
Status: Breaking Change - ENFORCED
What Changed: Hard limits now enforced on Workers Free plan
| Limit | Before Feb 10 | After Feb 10 |
|---|---|---|
| Databases per account | 10 (soft warning) | 10 (hard limit) |
| Database size | 500 MB (soft warning) | 500 MB (hard limit) |
| Queries per invocation | 50 (soft warning) | 50 (hard limit) |
Behavior:
- Before: Exceeding limits → Warnings logged, queries executed
- After: Exceeding limits → 429 Too Many Requests errors, queries fail
Error Examples:
// Exceeding 50 queries on free tier
// Error: D1_ERROR: Too many queries (50 per invocation on free plan)
// Database at 501 MB on free tier
// Error: D1_ERROR: Database size limit exceeded (500 MB on free plan)Migration Guide:
Step 1: Audit Current Usage
# List all databases and sizes
wrangler d1 list
# Check specific database size
wrangler d1 info my-databaseStep 2: Identify Issues
- >10 databases? → Consolidate or upgrade
- Database >400 MB? → Archive old data or upgrade
- Regularly >40 queries/invocation? → Implement batching or upgrade
Step 3: Optimize (if staying on free tier)
A. Implement Query Batching:
// Before: 100 queries (exceeds limit)
for (const user of users) {
await env.DB.prepare('INSERT INTO users VALUES (?, ?)').bind(user.name, user.email).run();
}
// After: 1 batch query
const statements = users.map(user =>
env.DB.prepare('INSERT INTO users VALUES (?, ?)').bind(user.name, user.email)
);
await env.DB.batch(statements); // Counts as 1 queryB. Archive Old Data:
-- Delete logs older than 90 days
DELETE FROM logs WHERE created_at < unixepoch() - 7776000;
PRAGMA optimize;C. Consolidate Databases (if under 10 limit):
# Export from old database
wrangler d1 execute old-database --command "SELECT * FROM users" > users.sql
# Import to consolidated database
wrangler d1 execute main-database --file=users.sqlStep 4: Upgrade to Paid (if needed)
Cost: $5/month minimum (Workers Paid plan)
Benefits:
- 50,000 databases (vs 10)
- 10 GB per database (vs 500 MB)
- 1,000 queries/invocation (vs 50)
- 30-day Time Travel (vs 7 days)
How to Upgrade: 1. Cloudflare dashboard → Workers & Pages 2. Plans → Upgrade to Workers Paid 3. Confirm billing
Full limits documentation: references/limits.md
---
Q2 2025: Read Replication
April 2025: Read Replication (Public Beta)
Status: Public Beta (Production-Ready)
Feature: Deploy read replicas globally for lower latency reads
Use Case: Read-heavy applications (e.g., content delivery, dashboards, search)
Benefits:
- Up to 2x read throughput
- Lower latency for reads from nearest region
- No additional cost (included in paid plan)
Limitations:
- Write queries still go to primary region
- Eventual consistency (~100ms lag typical)
- Paid plan only
Setup:
Option 1: wrangler CLI
# Enable read replication
wrangler d1 replicate enable my-databaseOption 2: wrangler.jsonc
{
"d1_databases": [{
"binding": "DB",
"database_name": "my-database",
"database_id": "abc123...",
"replicate": {
"enabled": true,
"regions": ["WEUR", "ENAM", "APAC"] // Optional: specify regions
}
}]
}Sessions API (Read-Write Separation):
import { D1Database } from '@cloudflare/workers-types';
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
if (request.method === 'GET') {
// Read from nearest replica (lower latency)
const readSession = env.DB.withSession({ mode: 'read-only' });
const users = await readSession
.prepare('SELECT * FROM users WHERE status = ?')
.bind('active')
.all();
return Response.json(users.results);
} else {
// Write to primary
const writeSession = env.DB.withSession({ mode: 'read-write' });
const { name, email } = await request.json();
await writeSession
.prepare('INSERT INTO users (name, email) VALUES (?, ?)')
.bind(name, email)
.run();
return Response.json({ success: true });
}
}
};Automatic Region Selection:
// Cloudflare automatically routes to nearest replica
const session = env.DB.withSession({ preferredRegion: 'auto' });
const users = await session.prepare('SELECT * FROM users').all();
// Check which region served the request
console.log('Served by:', users.meta.served_by_region);Consistency Guarantees:
- Eventual consistency: Replicas lag ~100ms behind primary (typical)
- Read-your-writes NOT guaranteed: Recent writes may not be visible immediately on replicas
- Recommendation: Use read-write session for critical reads after writes
Performance Impact:
- Read latency: 20-50% lower for users far from primary region
- Throughput: Up to 2x for read-heavy workloads
- Write latency: No change (still goes to primary)
Full read replication guide: references/read-replication.md
---
Q3 2025: Automatic Query Retries
September 2025: Automatic Retries
Status: Generally Available (All users)
Feature: D1 automatically retries read-only queries on transient failures
Behavior:
| Query Type | Automatic Retries | Backoff |
|---|---|---|
| Read queries (SELECT) | Up to 2 retries | 100ms, 200ms |
| Write queries (INSERT/UPDATE/DELETE) | No automatic retries | N/A |
How It Works: 1. Read query fails with transient error (network issue, timeout) 2. D1 waits 100ms, retries query 3. If still fails, waits 200ms, retries again 4. If still fails after 2 retries → Returns error to application
Transparent: Retries are logged in wrangler tail but invisible to application code
Example Log:
[D1] Query retry attempt 1/2: SELECT * FROM users WHERE email = ?
[D1] Query succeeded on retry 1Code Changes: None required (transparent)
When to Implement Application-Level Retries:
Still implement retries for write operations:
async function retryWrite<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
if (attempt === maxRetries - 1) throw error;
// Exponential backoff: 100ms, 200ms, 400ms
const delay = 100 * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Unreachable');
}
// Usage
await retryWrite(() =>
env.DB.prepare('INSERT INTO users (name, email) VALUES (?, ?)')
.bind('Alice', 'alice@example.com')
.run()
);Idempotency Considerations:
Ensure write operations are idempotent to safely retry:
// ❌ NOT idempotent (retry would create duplicate)
await env.DB.prepare('INSERT INTO users (email) VALUES (?)').bind(email).run();
// ✅ Idempotent (retry is safe)
await env.DB.prepare('INSERT OR IGNORE INTO users (email) VALUES (?)').bind(email).run();
// ✅ Idempotent with WHERE clause
await env.DB.prepare(
'UPDATE users SET last_login = ? WHERE email = ? AND last_login < ?'
).bind(Date.now(), email, Date.now()).run();Full best practices guide: references/best-practices.md
---
Q4 2025: Data Localization
November 2025: Jurisdiction-Specific Storage
Status: Generally Available (Paid Plans Only)
Feature: Specify data residency jurisdictions for compliance
Use Case: GDPR compliance, data sovereignty requirements, industry regulations
Available Jurisdictions:
- EU: European Union (GDPR Article 44-50 compliance)
- US: United States
- GLOBAL: Default (best performance, data may cross borders)
Configuration:
Option 1: wrangler CLI
# Create database with jurisdiction
wrangler d1 create my-database --jurisdiction EU
# Update existing database
wrangler d1 update my-database --jurisdiction EUOption 2: wrangler.jsonc
{
"d1_databases": [{
"binding": "DB",
"database_name": "my-database",
"database_id": "abc123...",
"jurisdiction": "EU" // or "US", "GLOBAL"
}]
}Compliance Guarantees:
- EU jurisdiction: Data stored and processed only in European Union datacenters
- US jurisdiction: Data stored and processed only in United States datacenters
- GLOBAL: Data may be stored/processed in any Cloudflare datacenter
Important: Metadata (table names, query logs) may still cross borders for platform operations
Performance Impact:
- Latency: ~10-30ms higher for requests outside jurisdiction
- Read replication: Still allowed within jurisdiction
- Throughput: No change
Example: GDPR Compliance:
// EU jurisdiction database for EU user data
const euSession = env.DB_EU.withSession({ jurisdiction: 'EU' });
await euSession.prepare(
'INSERT INTO users (email, country) VALUES (?, ?)'
).bind('user@example.eu', 'Germany').run();
// GLOBAL database for non-EU data
const globalSession = env.DB_GLOBAL;
await globalSession.prepare(
'INSERT INTO analytics (event, timestamp) VALUES (?, ?)'
).bind('page_view', Date.now()).run();Use Cases:
- GDPR (EU jurisdiction): European user data
- HIPAA (US jurisdiction): Healthcare data in United States
- Financial services: Regulatory data residency requirements
- Government contracts: Data sovereignty mandates
---
Migration Checklist
From v2.x to v3.x (2025 Features)
Step 1: Review Free Tier Usage (if on free plan)
# Check database count and sizes
wrangler d1 list
# Check query usage (via metrics dashboard or GraphQL API)
# If approaching limits, plan to upgrade or optimize- [ ] Database count < 10
- [ ] All databases < 400 MB
- [ ] Query count typically < 40 per invocation
Step 2: Test PRAGMA optimize
# Test in staging
wrangler d1 execute my-database --local --command "PRAGMA optimize"
# Apply in production (during low-traffic window)
wrangler d1 execute my-database --command "PRAGMA optimize"- [ ] No errors from PRAGMA optimize
- [ ] Query performance improved (check metrics)
Step 3: Enable Read Replication (if read-heavy, paid plan)
# Enable replication
wrangler d1 replicate enable my-database
# Update code to use Sessions API
# (see references/read-replication.md)- [ ] Read replicas enabled
- [ ] Code uses withSession() for read-write separation
- [ ] Metrics show improved read latency
Step 4: Verify Automatic Retry Behavior
# Monitor wrangler tail for retry logs
wrangler tail my-worker --format pretty | grep "retry"- [ ] Read queries retry automatically on transient failures
- [ ] Write queries implemented with application-level retries
Step 5: Configure Jurisdiction (if compliance required, paid plan)
# Set jurisdiction
wrangler d1 update my-database --jurisdiction EU- [ ] Jurisdiction configured (if needed)
- [ ] Compliance requirements met
Step 6: Update Monitoring
# Track new metrics (query efficiency, replica performance)
wrangler d1 insights my-database- [ ] Metrics dashboard reviewed
- [ ] Alerts configured for P95 latency, database size
- [ ] Weekly insights review scheduled
Step 7: Update Compatibility Date (recommended)
{
"compatibility_date": "2025-01-15" // Performance improvements
}- [ ] wrangler.jsonc updated
- [ ] Tested in staging
- [ ] Deployed to production
---
Breaking Changes Summary
February 10, 2025: Free Tier Enforcement
Impact: Users exceeding free tier limits will receive errors instead of warnings
Affected Users: Workers Free plan only
Mitigation:
- Implement query batching (reduce query count)
- Archive old data (reduce database size)
- Consolidate databases (reduce database count)
- Upgrade to Workers Paid ($5/month)
Timeline: Enforcement began February 10, 2025 (already in effect)
No Other Breaking Changes
All other 2025 features are additive and opt-in:
- Performance improvements: Automatic, no code changes
- Read replication: Opt-in, paid plan only
- Automatic retries: Automatic, backwards compatible
- Data localization: Opt-in, paid plan only
---
References
- Release Notes: https://developers.cloudflare.com/d1/platform/release-notes/
- Read Replication:
references/read-replication.md - Limits:
references/limits.md - Metrics:
references/metrics-analytics.md - Best Practices:
references/best-practices.md
---
Questions about 2025 updates?
1. Review this guide for feature details 2. Check release notes for latest announcements 3. Test new features in staging before production 4. Monitor metrics for performance impact 5. Upgrade to paid plan to unlock advanced features (read replication, data localization)
D1 Best Practices
Production-ready patterns for Cloudflare D1
---
Table of Contents
1. Security 2. Performance 3. Migrations 4. Error Handling 5. Data Modeling 6. Testing 7. Deployment
---
Security
Always Use Prepared Statements
// ❌ NEVER: SQL injection vulnerability
const email = c.req.query('email');
await env.DB.exec(`SELECT * FROM users WHERE email = '${email}'`);
// ✅ ALWAYS: Safe prepared statement
const user = await env.DB.prepare('SELECT * FROM users WHERE email = ?')
.bind(email)
.first();Why? User input like '; DROP TABLE users; -- would execute in the first example!
Use null Instead of undefined
// ❌ WRONG: undefined causes D1_TYPE_ERROR
await env.DB.prepare('INSERT INTO users (email, bio) VALUES (?, ?)')
.bind(email, undefined);
// ✅ CORRECT: Use null for optional values
await env.DB.prepare('INSERT INTO users (email, bio) VALUES (?, ?)')
.bind(email, bio || null);Never Commit Sensitive IDs
// ❌ WRONG: Database ID in public repo
{
"d1_databases": [
{
"database_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" // ❌
}
]
}
// ✅ BETTER: Use environment variable or secret
{
"d1_databases": [
{
"database_id": "$D1_DATABASE_ID" // Reference env var
}
]
}Or use wrangler secrets:
npx wrangler secret put D1_DATABASE_IDValidate Input Before Binding
// ✅ Validate email format
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
app.post('/api/users', async (c) => {
const { email } = await c.req.json();
if (!isValidEmail(email)) {
return c.json({ error: 'Invalid email format' }, 400);
}
// Now safe to use
const user = await c.env.DB.prepare('INSERT INTO users (email) VALUES (?)')
.bind(email)
.run();
});---
Performance
Use Batch for Multiple Queries
// ❌ BAD: 3 network round trips (~150ms)
const user = await env.DB.prepare('SELECT * FROM users WHERE user_id = ?').bind(1).first();
const posts = await env.DB.prepare('SELECT * FROM posts WHERE user_id = ?').bind(1).all();
const comments = await env.DB.prepare('SELECT * FROM comments WHERE user_id = ?').bind(1).all();
// ✅ GOOD: 1 network round trip (~50ms)
const [userResult, postsResult, commentsResult] = await env.DB.batch([
env.DB.prepare('SELECT * FROM users WHERE user_id = ?').bind(1),
env.DB.prepare('SELECT * FROM posts WHERE user_id = ?').bind(1),
env.DB.prepare('SELECT * FROM comments WHERE user_id = ?').bind(1)
]);
const user = userResult.results[0];
const posts = postsResult.results;
const comments = commentsResult.results;Performance win: 3x faster!
Create Indexes for WHERE Clauses
-- ❌ Slow: Full table scan
SELECT * FROM posts WHERE user_id = 123;
-- ✅ Fast: Create index first
CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id);
-- Now this query is fast
SELECT * FROM posts WHERE user_id = 123;Verify index is being used:
EXPLAIN QUERY PLAN SELECT * FROM posts WHERE user_id = 123;
-- Should see: SEARCH posts USING INDEX idx_posts_user_idRun PRAGMA optimize After Schema Changes
-- After creating indexes or altering schema
PRAGMA optimize;This collects statistics that help the query planner choose the best execution plan.
Select Only Needed Columns
// ❌ Bad: Fetches all columns (wastes bandwidth)
const users = await env.DB.prepare('SELECT * FROM users').all();
// ✅ Good: Only fetch what you need
const users = await env.DB.prepare('SELECT user_id, email, username FROM users').all();Always Use LIMIT
// ❌ Dangerous: Could return millions of rows
const posts = await env.DB.prepare('SELECT * FROM posts WHERE published = 1').all();
// ✅ Safe: Limit result set
const posts = await env.DB.prepare(
'SELECT * FROM posts WHERE published = 1 LIMIT 100'
).all();Use Partial Indexes
-- Index only published posts (smaller index, faster writes)
CREATE INDEX idx_posts_published ON posts(created_at DESC)
WHERE published = 1;
-- Index only active users (exclude deleted)
CREATE INDEX idx_users_active ON users(email)
WHERE deleted_at IS NULL;Benefits:
- ✅ Smaller indexes (faster queries)
- ✅ Fewer index updates (faster writes)
- ✅ Only index relevant data
---
Read Replication (Beta)
Status: Beta (as of 2025-11-11) Reference: See read-replication.md for complete guide Official Docs: https://developers.cloudflare.com/d1/best-practices/read-replication/
What It Is
D1 read replication creates asynchronously replicated read-only database copies across Cloudflare's global network (6 regions: ENAM, WNAM, WEUR, EEUR, APAC, OC). This reduces read latency and increases throughput by routing queries to replicas closer to users.
Free feature included with D1 at no additional cost.
Enabling Read Replication
Dashboard Method: 1. Navigate to Workers & Pages > D1 2. Select your database > Settings 3. Enable Read Replication
API Method:
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/{account_id}/d1/database/{database_id}" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"read_replication": {"mode": "auto"}}'Sessions API Patterns
Unconstrained (Any Instance)
Use when slight staleness is acceptable:
// Routes to nearest replica
const session = env.DB.withSession();
const products = await session
.prepare('SELECT * FROM products WHERE category = ?')
.bind('electronics')
.all();Best for: Product catalogs, blogs, public content
Primary-First (Latest Data)
Use when you need the most current data:
// Always routes to primary
const session = env.DB.withSession('first-primary');
const user = await session
.prepare('SELECT * FROM users WHERE user_id = ?')
.bind(userId)
.first();Best for: User profiles, account settings, financial data
Bookmark-Based (Consistent Workflow)
Use for multi-step workflows:
// Get bookmark from previous request
const bookmark = c.req.header('x-d1-bookmark') ?? 'first-unconstrained';
const session = env.DB.withSession(bookmark);
// Perform query
const result = await session.prepare(query).bind(...params).run();
// Return bookmark for next request
return c.json(data, 200, {
'x-d1-bookmark': session.getBookmark() ?? ''
});Best for: Checkout flows, wizards, shopping carts
Monitoring
Track which instance served your query:
const result = await session.prepare(query).run();
console.log({
servedByRegion: result.meta.served_by_region,
servedByPrimary: result.meta.served_by_primary,
rowsRead: result.meta.rows_read
});When to Use
✅ Use When:
- Globally distributed users
- Read-heavy workload (reads >> writes)
- Read latency is a performance bottleneck
- Can integrate Sessions API
- Tolerate eventual consistency with bookmarks
❌ Don't Use When:
- Single-region application (no benefit)
- Requires strong consistency without Sessions API
- Write-heavy workload
- Cannot implement Sessions API
Common Pitfalls
❌ Forgetting Sessions API:
// Bad: Can read stale data after write
await env.DB.prepare('INSERT INTO posts VALUES (?)').bind('New').run();
const posts = await env.DB.prepare('SELECT * FROM posts').all(); // Might not see new post✅ Using Sessions API:
// Good: Guaranteed consistency
const session = env.DB.withSession('first-primary');
await session.prepare('INSERT INTO posts VALUES (?)').bind('New').run();
const posts = await session.prepare('SELECT * FROM posts').all(); // Always sees new post❌ Not Passing Bookmarks:
// Bad: Loses consistency across requests
app.post('/cart/add', async (c) => {
const session = env.DB.withSession();
// ... add to cart ...
return c.json({ success: true }); // No bookmark returned!
});✅ Passing Bookmarks:
// Good: Maintains consistency
app.post('/cart/add', async (c) => {
const bookmark = c.req.header('x-d1-bookmark') ?? 'first-unconstrained';
const session = env.DB.withSession(bookmark);
// ... add to cart ...
return c.json({ success: true }, 200, {
'x-d1-bookmark': session.getBookmark() ?? ''
});
});Limitations (Beta)
⚠️ Current Limitations:
- Sessions API only via Worker Binding (not REST API yet)
- Disabling replication takes up to 24 hours to propagate
- All writes always route to primary instance
- Replica lag typically < 1 second (but not guaranteed)
Best Practices Checklist
- [ ] Enable read replication via dashboard or API
- [ ] Update code to use Sessions API (
withSession()) - [ ] Implement bookmark passing for multi-step workflows
- [ ] Use
first-primaryafter writes if immediate reads needed - [ ] Monitor
served_by_regionandserved_by_primarymetrics - [ ] Test with replication enabled in development
- [ ] Document which routes use which session pattern
For complete examples and migration guide: See read-replication.md
---
Migrations
Make Migrations Idempotent
-- ✅ ALWAYS use IF NOT EXISTS
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY,
email TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- ✅ Use IF EXISTS for drops
DROP TABLE IF EXISTS temp_table;Why? Re-running a migration won't fail if it's already applied.
Never Modify Applied Migrations
# ❌ WRONG: Editing applied migration
vim migrations/0001_create_users.sql # Already applied!
# ✅ CORRECT: Create new migration
npx wrangler d1 migrations create my-database add_users_bio_columnWhy? D1 tracks which migrations have been applied. Modifying them causes inconsistencies.
Test Migrations Locally First
# 1. Apply to local database
npx wrangler d1 migrations apply my-database --local
# 2. Test queries locally
npx wrangler d1 execute my-database --local --command "SELECT * FROM users"
# 3. Only then apply to production
npx wrangler d1 migrations apply my-database --remoteHandle Foreign Keys Carefully
-- Disable foreign key checks temporarily during schema changes
PRAGMA defer_foreign_keys = true;
-- Make schema changes that would violate foreign keys
ALTER TABLE posts DROP COLUMN old_user_id;
ALTER TABLE posts ADD COLUMN user_id INTEGER REFERENCES users(user_id);
-- Foreign keys re-enabled automatically at end of migrationBreak Large Data Migrations into Batches
-- ❌ BAD: Single massive INSERT (causes "statement too long")
INSERT INTO users (email) VALUES
('user1@example.com'),
('user2@example.com'),
... -- 10,000 more rows
-- ✅ GOOD: Split into batches of 100-250 rows
-- File: 0001_migrate_users_batch1.sql
INSERT INTO users (email) VALUES
('user1@example.com'),
... -- 100 rows
-- File: 0002_migrate_users_batch2.sql
INSERT INTO users (email) VALUES
('user101@example.com'),
... -- next 100 rows---
Error Handling
Check for Errors After Every Query
try {
const result = await env.DB.prepare('INSERT INTO users (email) VALUES (?)')
.bind(email)
.run();
if (!result.success) {
console.error('Insert failed');
return c.json({ error: 'Failed to create user' }, 500);
}
// Success!
const userId = result.meta.last_row_id;
} catch (error: any) {
console.error('Database error:', error.message);
return c.json({ error: 'Database operation failed' }, 500);
}Implement Retry Logic for Transient Errors
async function queryWithRetry<T>(
queryFn: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await queryFn();
} catch (error: any) {
const message = error.message;
// Check if error is retryable
const isRetryable =
message.includes('Network connection lost') ||
message.includes('storage caused object to be reset') ||
message.includes('reset because its code was updated');
if (!isRetryable || attempt === maxRetries - 1) {
throw error;
}
// Exponential backoff: 1s, 2s, 4s
const delay = Math.min(1000 * Math.pow(2, attempt), 5000);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Max retries exceeded');
}
// Usage
const user = await queryWithRetry(() =>
env.DB.prepare('SELECT * FROM users WHERE user_id = ?')
.bind(userId)
.first()
);Handle Common D1 Errors
try {
await env.DB.prepare(query).bind(...params).run();
} catch (error: any) {
const message = error.message;
if (message.includes('D1_ERROR')) {
// D1-specific error
console.error('D1 error:', message);
} else if (message.includes('UNIQUE constraint failed')) {
// Duplicate key error
return c.json({ error: 'Email already exists' }, 409);
} else if (message.includes('FOREIGN KEY constraint failed')) {
// Invalid foreign key
return c.json({ error: 'Invalid user reference' }, 400);
} else {
// Unknown error
console.error('Unknown database error:', message);
return c.json({ error: 'Database operation failed' }, 500);
}
}---
Automatic Query Retries (2025)
Status: Generally Available (September 2025) Official Docs: https://developers.cloudflare.com/d1/best-practices/automatic-retries/
What It Is
D1 automatically retries read-only queries (SELECT) up to 2 times on transient failures like network timeouts, connection resets, or temporary database unavailability. This feature improves reliability without requiring application-level retry logic.
Scope: Read queries only (SELECT statements) Retry Count: Up to 2 automatic retries Backoff: Exponential backoff (specific timing not published by Cloudflare) No Action Required: Enabled automatically for all D1 databases
How It Works
// This query automatically retries on transient failures
const user = await env.DB.prepare('SELECT * FROM users WHERE user_id = ?')
.bind(userId)
.first();
// If first attempt fails with transient error:
// - D1 automatically retries with exponential backoff
// - Up to 2 additional attempts (3 total)
// - Throws error if all attempts failRetryable Errors:
- Network connection lost
- Database temporarily unavailable
- Connection reset by peer
- Timeout errors (execution > 30 seconds)
Non-Retryable Errors (fail immediately):
- SQL syntax errors
- Constraint violations (UNIQUE, FOREIGN KEY)
- Permission errors
- Query limit exceeded (50 queries/invocation on free tier)
Write Operations (Manual Retry Required)
Write operations (INSERT, UPDATE, DELETE) do NOT automatically retry. Implement application-level retry logic for write queries:
async function writeWithRetry<T>(
queryFn: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await queryFn();
} catch (error: unknown) {
// D1 errors are Error objects, but validate to be safe
if (!(error instanceof Error)) {
throw error;
}
// Note: D1 does not publish specific error codes for transient errors.
// We check error.message as recommended by Cloudflare documentation.
// See: https://developers.cloudflare.com/d1/observability/debug-d1/
const message = error.message || '';
// Check if error is retryable (transient network/connection issues)
const isRetryable =
message.includes('Network connection lost') ||
message.includes('storage caused object to be reset') ||
message.includes('reset because its code was updated') ||
message.includes('timeout') ||
message.includes('D1_ERROR');
// Don't retry on final attempt or non-retryable errors
if (!isRetryable || attempt === maxRetries - 1) {
// Log full error details before throwing
console.error('D1 write failed:', {
message: error.message,
name: error.name,
attempt: attempt + 1,
retryable: isRetryable
});
throw error;
}
// Exponential backoff: 1s, 2s, 4s (application-level)
const delay = Math.min(1000 * Math.pow(2, attempt), 5000);
await new Promise(resolve => setTimeout(resolve, delay));
console.log(`Retrying write operation (attempt ${attempt + 2}/${maxRetries})`);
}
}
throw new Error('Max retries exceeded');
}
// Usage
const result = await writeWithRetry(() =>
env.DB.prepare('UPDATE users SET credits = credits - ? WHERE user_id = ?')
.bind(amount, userId)
.run()
);Idempotency Best Practices
When implementing manual retry logic for writes, ensure operations are idempotent (safe to execute multiple times):
// ❌ NOT Idempotent: Repeated retries would deduct credits multiple times
await env.DB.prepare('UPDATE users SET credits = credits - 100 WHERE user_id = ?')
.bind(userId)
.run();
// ✅ Idempotent: Check current balance first, only deduct if sufficient
const user = await env.DB.prepare('SELECT credits FROM users WHERE user_id = ?')
.bind(userId)
.first();
if (user.credits >= 100) {
await env.DB.prepare('UPDATE users SET credits = ? WHERE user_id = ?')
.bind(user.credits - 100, userId)
.run();
}Idempotency Patterns:
1. Check-Then-Set:
// Verify state before writing
const existing = await env.DB.prepare('SELECT * FROM orders WHERE order_id = ?')
.bind(orderId)
.first();
if (!existing) {
await env.DB.prepare('INSERT INTO orders (order_id, status) VALUES (?, ?)')
.bind(orderId, 'pending')
.run();
}2. Unique Constraint:
CREATE TABLE orders (
order_id TEXT PRIMARY KEY, -- Unique constraint prevents duplicates
status TEXT NOT NULL,
created_at INTEGER DEFAULT (unixepoch())
); try {
await env.DB.prepare('INSERT INTO orders (order_id, status) VALUES (?, ?)')
.bind(orderId, 'pending')
.run();
} catch (error: any) {
if (error.message.includes('UNIQUE constraint failed')) {
// Order already exists, safe to continue
console.log('Order already created');
} else {
throw error;
}
}3. Upsert Instead of Insert:
// INSERT or UPDATE - safe to retry
await env.DB.prepare(`
INSERT INTO user_settings (user_id, theme)
VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET theme = excluded.theme
`).bind(userId, theme).run();Monitoring Retry Behavior
Track retry frequency to identify underlying issues:
let retryCount = 0;
app.use('*', async (c, next) => {
const start = Date.now();
try {
await next();
} catch (error: any) {
retryCount++;
console.warn({
retryCount,
error: error.message,
duration: Date.now() - start,
endpoint: c.req.path
});
throw error;
}
});
// Alert if retry rate exceeds threshold
if (retryCount > 10) {
console.error('High retry rate detected - investigate database health');
}---
Data Modeling
Use Appropriate Data Types
CREATE TABLE users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT, -- Auto-incrementing ID
email TEXT NOT NULL, -- String
username TEXT NOT NULL,
age INTEGER, -- Number
balance REAL, -- Decimal/float
is_active INTEGER DEFAULT 1, -- Boolean (0 or 1)
metadata TEXT, -- JSON (stored as TEXT)
created_at INTEGER NOT NULL -- Unix timestamp
);SQLite has 5 types: NULL, INTEGER, REAL, TEXT, BLOB
Store Timestamps as Unix Epoch
-- ✅ RECOMMENDED: Unix timestamp (INTEGER)
created_at INTEGER NOT NULL DEFAULT (unixepoch())
-- ❌ AVOID: ISO 8601 strings (harder to query/compare)
created_at TEXT NOT NULL DEFAULT (datetime('now'))Why? Unix timestamps are easier to compare, filter, and work with in JavaScript:
// Easy to work with
const timestamp = Date.now(); // 1698000000
const date = new Date(timestamp);
// Easy to query
const recentPosts = await env.DB.prepare(
'SELECT * FROM posts WHERE created_at > ?'
).bind(Date.now() - 86400000).all(); // Last 24 hoursStore JSON as TEXT
CREATE TABLE users (
user_id INTEGER PRIMARY KEY,
email TEXT NOT NULL,
settings TEXT -- Store JSON here
);// Insert JSON
const settings = { theme: 'dark', language: 'en' };
await env.DB.prepare('INSERT INTO users (email, settings) VALUES (?, ?)')
.bind(email, JSON.stringify(settings))
.run();
// Read JSON
const user = await env.DB.prepare('SELECT * FROM users WHERE user_id = ?')
.bind(userId)
.first();
const settings = JSON.parse(user.settings);
console.log(settings.theme); // 'dark'Use Soft Deletes
CREATE TABLE users (
user_id INTEGER PRIMARY KEY,
email TEXT NOT NULL,
deleted_at INTEGER -- NULL = active, timestamp = deleted
);
-- Index for active users only
CREATE INDEX idx_users_active ON users(user_id)
WHERE deleted_at IS NULL;// Soft delete
await env.DB.prepare('UPDATE users SET deleted_at = ? WHERE user_id = ?')
.bind(Date.now(), userId)
.run();
// Query only active users
const activeUsers = await env.DB.prepare(
'SELECT * FROM users WHERE deleted_at IS NULL'
).all();Normalize Related Data
-- ✅ GOOD: Normalized (users in separate table)
CREATE TABLE posts (
post_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id)
);
-- ❌ BAD: Denormalized (user data duplicated in every post)
CREATE TABLE posts (
post_id INTEGER PRIMARY KEY,
user_email TEXT NOT NULL,
user_name TEXT NOT NULL,
title TEXT NOT NULL
);---
Testing
Test Migrations Locally
# 1. Create local database
npx wrangler d1 migrations apply my-database --local
# 2. Seed with test data
npx wrangler d1 execute my-database --local --file=seed.sql
# 3. Run test queries
npx wrangler d1 execute my-database --local --command "SELECT COUNT(*) FROM users"Use Separate Databases for Development
{
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-prod",
"database_id": "<PROD_UUID>",
"preview_database_id": "local-dev" // Local only
}
]
}Benefits:
- ✅ Never accidentally modify production data
- ✅ Fast local development (no network latency)
- ✅ Can reset local DB anytime
Backup Before Major Migrations
# Export current database
npx wrangler d1 export my-database --remote --output=backup-$(date +%Y%m%d).sql
# Apply migration
npx wrangler d1 migrations apply my-database --remote
# If something goes wrong, restore from backup
npx wrangler d1 execute my-database --remote --file=backup-20251021.sql---
Deployment
Use Preview Databases for Testing
{
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-prod",
"database_id": "<PROD_UUID>",
"preview_database_id": "<PREVIEW_UUID>" // Separate preview database
}
]
}Deploy preview:
npx wrangler deploy --env previewApply Migrations Before Deploying Code
# 1. Apply migrations first
npx wrangler d1 migrations apply my-database --remote
# 2. Then deploy Worker code
npx wrangler deployWhy? Ensures database schema is ready before code expects it.
Monitor Query Performance
app.get('/api/users', async (c) => {
const start = Date.now();
const { results, meta } = await c.env.DB.prepare('SELECT * FROM users LIMIT 100')
.all();
const duration = Date.now() - start;
// Log slow queries
if (duration > 100) {
console.warn(`Slow query: ${duration}ms, rows_read: ${meta.rows_read}`);
}
return c.json({ users: results });
});Use Time Travel for Data Recovery
# View database state 2 hours ago
npx wrangler d1 time-travel info my-database --timestamp "2025-10-21T10:00:00Z"
# Restore database to 2 hours ago
npx wrangler d1 time-travel restore my-database --timestamp "2025-10-21T10:00:00Z"Note: Time Travel available for last 30 days.
---
Data Localization (2025)
Status: Generally Available (November 2025) Official Docs: https://developers.cloudflare.com/d1/configuration/data-location/
What It Is
Data localization allows you to specify which geographic region stores your D1 database's primary instance. This ensures compliance with data sovereignty regulations like GDPR (EU) or industry-specific requirements (financial services, government).
Available Jurisdictions:
- eu: European Union (GDPR compliant)
- fedramp: FedRAMP-compliant data centers (requires Enterprise plan)
Important Notes:
- Jurisdictions are immutable and can only be set at database creation
- Cannot be changed after the database is created
- If no jurisdiction is specified, D1 uses location hints for optimal placement
- For US placement without compliance requirements, use location hints (wnam/enam) instead of jurisdiction
Cost: Free feature (fedramp jurisdiction requires Enterprise plan)
Configuration
Method 1: Wrangler CLI (Database Creation)
# Create database with EU jurisdiction
wrangler d1 create my-database --jurisdiction eu
# Create database with FedRAMP jurisdiction (requires Enterprise plan)
wrangler d1 create my-database --jurisdiction fedramp
# Create database without jurisdiction (uses location hints for optimal placement)
wrangler d1 create my-databaseMethod 2: wrangler.jsonc (Existing Database)
{
"d1_databases": [
{
"binding": "DB",
"database_name": "my-database",
"database_id": "abc123-def456-...",
"jurisdiction": "eu" // or "fedramp" (Enterprise only)
}
]
}Critical: Jurisdiction can only be set during database creation via CLI. Cannot be changed or added after creation. The wrangler.jsonc jurisdiction field is for reference only; it must match the jurisdiction set during wrangler d1 create.
Use Cases
GDPR Compliance (EU Jurisdiction)
// Healthcare app serving EU users
{
"d1_databases": [
{
"binding": "PATIENT_DB",
"database_name": "patient-records",
"database_id": "...",
"jurisdiction": "eu" // Ensures data stays in EU (GDPR compliant)
}
]
}Benefits:
- ✅ GDPR Article 44-50 compliance (data transfers)
- ✅ Data residency guarantee (EU only)
- ✅ Simplified regulatory audits
US-Based Compliance (HIPAA, GLBA, SOX)
Important: There is no "US" jurisdiction in Cloudflare D1. For US-based compliance:
- Use location hints (wnam/enam) for US regional placement (not a compliance guarantee)
- Use fedramp jurisdiction (Enterprise plan) for federal/government compliance requirements
- Consult legal counsel for specific compliance requirements
// Healthcare app serving US patients (using location hints)
{
"d1_databases": [
{
"binding": "MEDICAL_DB",
"database_name": "medical-records",
"database_id": "...",
// No jurisdiction - use location hints for US placement
"location_hint": "wnam" // Western North America
}
]
}For FedRAMP Compliance (Enterprise Only):
wrangler d1 create medical-records --jurisdiction fedrampBenefits of Location Hints:
- ✅ US regional placement (wnam/enam)
- ✅ Reduced latency for US users
- ⚠️ Not a compliance guarantee (consult legal counsel)
Benefits of FedRAMP Jurisdiction (Enterprise):
- ✅ Federal compliance certification
- ✅ Government-grade data security
- ✅ Strict data residency guarantees
Financial Services
Note: Use location hints for regional placement or fedramp for federal compliance.
// Banking app with regulatory requirements
{
"d1_databases": [
{
"binding": "TRANSACTION_DB",
"database_name": "transactions",
"database_id": "...",
"location_hint": "wnam" // US placement (not jurisdiction)
}
]
}For federal financial compliance:
wrangler d1 create transactions --jurisdiction fedramp # Enterprise onlyRegulations Addressed:
- SOX (Sarbanes-Oxley Act)
- GLBA (Gramm-Leach-Bliley Act)
- PCI DSS (Payment Card Industry Data Security Standard)
Performance Impact
Latency Outside Jurisdiction (~10-30ms):
| User Location | DB Jurisdiction | Expected P50 Latency | Impact |
|---|---|---|---|
| Europe | eu | ~15ms | ✅ Optimal |
| Europe | (wnam hint) | ~40ms | ⚠️ +25ms cross-region |
| Europe | (no jurisdiction) | ~15ms | ✅ Optimal (location hints) |
| US East | (enam hint) | ~12ms | ✅ Optimal |
| US East | eu | ~35ms | ⚠️ +23ms cross-region |
| US East | (no jurisdiction) | ~12ms | ✅ Optimal (location hints) |
Best Practice: Use location hints (default) unless regulatory requirements mandate eu or fedramp jurisdiction.
When to Use Each Jurisdiction
Use Location Hints (Default) When:
- ✅ No regulatory data residency requirements
- ✅ Users are globally distributed
- ✅ Performance is top priority
- ✅ Want Cloudflare to auto-optimize location based on traffic patterns
Use eu Jurisdiction When:
- ✅ GDPR compliance required (mandatory EU data residency)
- ✅ Majority of users in Europe
- ✅ Industry regulations mandate EU storage (healthcare, finance)
- ✅ Data processing agreements require EU residency guarantees
Use fedramp Jurisdiction When (Enterprise Only):
- ✅ Federal/government compliance required
- ✅ FedRAMP certification needed
- ✅ Serving US government agencies
- ✅ Strict federal data security requirements
For US Regional Placement (Without Jurisdiction):
- ✅ Use location hints: wnam (Western North America) or enam (Eastern North America)
- ⚠️ Not a compliance guarantee - consult legal counsel for HIPAA, SOX, GLBA
- ✅ Provides lower latency for US users without jurisdiction restrictions
Verification
Check database jurisdiction via wrangler:
wrangler d1 info my-databaseOutput includes jurisdiction:
Database: my-database
UUID: abc123-def456-...
Location: EU ← Jurisdiction
Version: ...Migration Considerations
Cannot Change Jurisdiction After Creation:
If jurisdiction needs to change, you must: 1. Create new database with desired jurisdiction 2. Export data from old database 3. Import data into new database 4. Update Worker bindings 5. Deploy updated Worker 6. Delete old database
# 1. Create new database with correct jurisdiction
wrangler d1 create my-database-eu --jurisdiction eu
# 2. Export data from old database
wrangler d1 export my-database --output=backup.sql
# 3. Import into new database
wrangler d1 execute my-database-eu --file=backup.sql
# 4. Update wrangler.jsonc with new database_id
# 5. Deploy Worker with new binding
wrangler deploy
# 6. After verifying, delete old database
wrangler d1 delete my-databaseRead Replication Interaction
Data localization works seamlessly with read replication:
- Primary instance: Stored in specified jurisdiction (eu/fedramp) or optimal location (if no jurisdiction set)
- Read replicas: Distributed globally across all 6 regions
- Writes: Always route to primary (in jurisdiction)
- Reads: Can route to nearest replica (regardless of jurisdiction)
Example Configuration:
{
"d1_databases": [
{
"binding": "DB",
"database_name": "my-database",
"database_id": "...",
"jurisdiction": "EU", // Primary in EU
"replicate": {
"enabled": true // Replicas in all 6 regions
}
}
]
}Result:
- Writes stored in EU (compliant)
- Reads served from nearest replica globally (fast)
- Best of both: compliance + performance
---
Summary Checklist
Security ✅
- [ ] Always use
.prepare().bind()for user input - [ ] Use
nullinstead ofundefined - [ ] Validate input before binding
- [ ] Never commit database IDs to public repos
Performance ✅
- [ ] Use
.batch()for multiple queries - [ ] Create indexes on filtered columns
- [ ] Run
PRAGMA optimizeafter schema changes - [ ] Select only needed columns
- [ ] Always use
LIMIT
Migrations ✅
- [ ] Make migrations idempotent (IF NOT EXISTS)
- [ ] Never modify applied migrations
- [ ] Test locally before production
- [ ] Break large data migrations into batches
Error Handling ✅
- [ ] Wrap queries in try/catch
- [ ] Implement retry logic for transient errors
- [ ] Check
result.successandmeta.rows_written - [ ] Log errors with context
Data Modeling ✅
- [ ] Use appropriate SQLite data types
- [ ] Store timestamps as Unix epoch (INTEGER)
- [ ] Use soft deletes (deleted_at column)
- [ ] Normalize related data with foreign keys
Testing ✅
- [ ] Test migrations locally first
- [ ] Use separate development/production databases
- [ ] Backup before major migrations
Deployment ✅
- [ ] Apply migrations before deploying code
- [ ] Use preview databases for testing
- [ ] Monitor query performance
- [ ] Use Time Travel for recovery
---
Official Documentation
- Best Practices: https://developers.cloudflare.com/d1/best-practices/
- Indexes: https://developers.cloudflare.com/d1/best-practices/use-indexes/
- Local Development: https://developers.cloudflare.com/d1/best-practices/local-development/
- Retry Queries: https://developers.cloudflare.com/d1/best-practices/retry-queries/
- Time Travel: https://developers.cloudflare.com/d1/reference/time-travel/
Cloudflare D1 Limits and Quotas
Purpose: Comprehensive D1 limits documentation for debugging and capacity planning
Last Updated: 2025-01-15
---
Table of Contents
1. Overview 2. Database Limits 3. Query Limits 4. Data Structure Constraints 5. Free Tier Enforcement (February 10, 2025) 6. Handling Limit Errors 7. Monitoring Limits 8. Best Practices
---
Overview
D1 enforces limits based on account tier:
- Free Plan: Workers Free ($0/month)
- Paid Plan: Workers Paid ($5/month minimum)
Critical Change: Free tier hard limits enforced starting February 10, 2025. Previously warnings only; now returns errors.
---
Database Limits
Storage Quotas
| Limit | Free Tier | Paid Plan | Increasable? |
|---|---|---|---|
| Databases per account | 10 | 50,000 | Yes (request increase) |
| Maximum database size | 500 MB | 10 GB | No (hard limit) |
| Maximum account storage | 5 GB | 1 TB | Yes (request increase) |
| Maximum row size | 2 MB | 2 MB | No |
| String/BLOB size | 2 MB | 2 MB | No |
Important:
- The 10 GB maximum database size cannot be increased (SQLite limitation)
- Account storage = sum of all database sizes
- Row size includes all columns combined (2 MB total)
Time Travel Retention
| Tier | Retention | Restores per 10 min |
|---|---|---|
| Free | 7 days | 10 |
| Paid | 30 days | 10 |
Time Travel allows point-in-time restore:
wrangler d1 time-travel restore my-database --timestamp=2025-01-15T14:30:00Z---
Query Limits
Per-Invocation Limits
| Limit | Free Tier | Paid Plan |
|---|---|---|
| Queries per Worker invocation | 50 | 1,000 |
| Maximum SQL statement length | 100 KB | 100 KB |
| Maximum query duration | 30 seconds | 30 seconds |
| Bound parameters per query | 100 | 100 |
| SQL function arguments | 32 | 32 |
| Simultaneous connections | 6 | 6 |
Key Concepts:
Worker Invocation: One execution of your Worker (one HTTP request = one invocation)
// ❌ BAD: 55 queries in one invocation (exceeds free tier limit of 50)
export default {
async fetch(request: Request, env: Env) {
for (let i = 0; i < 55; i++) {
await env.DB.prepare('INSERT INTO logs VALUES (?)').bind(i).run();
}
}
};Solution: Use batch queries
// ✅ GOOD: 1 batch query with 55 statements
export default {
async fetch(request: Request, env: Env) {
const statements = Array.from({ length: 55 }, (_, i) =>
env.DB.prepare('INSERT INTO logs VALUES (?)').bind(i)
);
await env.DB.batch(statements); // Counts as 1 query invocation
}
};SQLite Variable Limit
Critical: SQLite has a hard limit of 999 variables per statement:
// ❌ FAILS: 1000+ bind parameters
const values = Array.from({ length: 500 }, (_, i) => `(?, ?)`).join(', ');
await env.DB.prepare(`INSERT INTO users (name, email) VALUES ${values}`).bind(...names, ...emails).run();
// Error: "too many SQL variables"Solution: Batch in chunks of 100-400 rows
// ✅ GOOD: Chunk into batches of 100 rows (200 variables)
const BATCH_SIZE = 100;
for (let i = 0; i < users.length; i += BATCH_SIZE) {
const chunk = users.slice(i, i + BATCH_SIZE);
const placeholders = chunk.map(() => '(?, ?)').join(', ');
const values = chunk.flatMap(u => [u.name, u.email]);
await env.DB.prepare(
`INSERT INTO users (name, email) VALUES ${placeholders}`
).bind(...values).run();
}---
Data Structure Constraints
Table Limits
| Limit | Value |
|---|---|
| Columns per table | 100 |
| Rows per table | Unlimited (subject to database size) |
| LIKE/GLOB pattern length | 50 bytes |
Connection Limits
Simultaneous connections: 6 per Worker invocation
// ✅ GOOD: Sequential queries (1 connection)
await env.DB.prepare('SELECT * FROM users').all();
await env.DB.prepare('SELECT * FROM posts').all();
// ✅ GOOD: Parallel queries (6 connections max)
await Promise.all([
env.DB.prepare('SELECT * FROM users').all(),
env.DB.prepare('SELECT * FROM posts').all(),
env.DB.prepare('SELECT * FROM comments').all(),
env.DB.prepare('SELECT * FROM likes').all(),
env.DB.prepare('SELECT * FROM shares').all(),
env.DB.prepare('SELECT * FROM follows').all(),
]);
// ⚠️ WARNING: 7+ parallel queries may hit connection limit---
Free Tier Enforcement (February 10, 2025)
What Changed
Before February 10, 2025:
- Exceeding free tier limits → Warnings logged, queries still executed
- Soft enforcement (no hard errors)
After February 10, 2025:
- Exceeding free tier limits → 429 Too Many Requests error
- Hard enforcement (queries fail)
- Daily reset at midnight UTC
Limits Enforced
1. Database count: Maximum 10 databases 2. Database size: Maximum 500 MB per database 3. Queries per invocation: Maximum 50 queries per Worker execution
Error Examples
429 Error (Query Limit):
// Request with 55 queries on free tier
// Error: D1_ERROR: Too many queries (50 per invocation on free plan)Database Full Error:
// Database at 501 MB on free tier
// Error: D1_ERROR: Database size limit exceeded (500 MB on free plan)Database Count Error:
# Attempting to create 11th database on free tier
$ wrangler d1 create my-database-11
# Error: Account database limit reached (10 databases on free plan)Migration Guide
If hitting limits:
1. Check current usage:
wrangler d1 list
wrangler d1 info my-database2. Options:
- Upgrade to paid plan ($5/month) → 1,000 queries/invocation, 50,000 databases, 10 GB per database
- Optimize queries → Use batching to reduce query count
- Archive old data → Reduce database size
- Consolidate databases → Merge databases if under 10 limit
3. Implement query batching:
// Before: 100 queries
for (const user of users) {
await env.DB.prepare('INSERT INTO users VALUES (?, ?)').bind(user.name, user.email).run();
}
// After: 1 batch query
const statements = users.map(user =>
env.DB.prepare('INSERT INTO users VALUES (?, ?)').bind(user.name, user.email)
);
await env.DB.batch(statements);---
Handling Limit Errors
429 Too Many Requests
Error Message: D1_ERROR: Too many queries
Cause: Exceeded 50 queries per invocation (free tier) or 1,000 queries (paid tier)
Solutions:
1. Batch queries:
// Instead of 100 separate queries, use 1 batch
await env.DB.batch(statements);2. Implement query queue (if upgrading to paid not feasible):
// Queue excess queries for next invocation
const MAX_QUERIES = 50; // Free tier limit
if (statements.length > MAX_QUERIES) {
// Execute first batch
await env.DB.batch(statements.slice(0, MAX_QUERIES));
// Queue remaining for later (via Durable Objects or Queue)
await queueForLater(statements.slice(MAX_QUERIES));
} else {
await env.DB.batch(statements);
}3. Upgrade to paid plan:
# Increases limit from 50 → 1,000 queries per invocation
# Visit Cloudflare dashboard → Workers & Pages → PlansDatabase Size Limit
Error Message: D1_ERROR: Database size limit exceeded
Cause: Database exceeds 500 MB (free) or 10 GB (paid)
Solutions:
1. Archive old data:
-- Move old records to archive table (then export/delete)
CREATE TABLE users_archive AS
SELECT * FROM users WHERE created_at < unixepoch() - 31536000; -- 1 year ago
DELETE FROM users WHERE created_at < unixepoch() - 31536000;
PRAGMA optimize;2. Implement data retention policy:
// Monthly cleanup job (via Cron Triggers)
export default {
async scheduled(event: ScheduledEvent, env: Env) {
// Delete logs older than 90 days
await env.DB.prepare(
'DELETE FROM logs WHERE created_at < ?'
).bind(Date.now() - 90 * 24 * 60 * 60 * 1000).run();
await env.DB.prepare('PRAGMA optimize').run();
}
};3. Split data across databases (if under database count limit):
// Shard by time period
// DB_2024: Historical data
// DB_2025: Current data
const year = new Date().getFullYear();
const db = year === 2025 ? env.DB_2025 : env.DB_2024;---
Monitoring Limits
Check Database Size
# View database info
wrangler d1 info my-database
# Output includes:
# - Total size (MB)
# - Number of tables
# - Last backup timestampMonitor Query Usage
Via Metrics Dashboard: 1. Navigate to Cloudflare dashboard 2. Workers & Pages → D1 3. Select database → Metrics tab 4. View "Queries per Second" graph
Via GraphQL API:
query GetD1Metrics($accountId: String!, $databaseId: String!) {
viewer {
accounts(filter: {accountTag: $accountId}) {
d1AnalyticsAdaptiveGroups(
filter: {databaseId: $databaseId}
limit: 1000
) {
dimensions { ts }
sum { readQueries writeQueries }
}
}
}
}Full monitoring guide: Load references/metrics-analytics.md
Track Approaching Limits
Set up alerts:
// Example: Log warning if approaching query limit
let queryCount = 0;
export default {
async fetch(request: Request, env: Env) {
const MAX_QUERIES = 50; // Free tier
const WARN_THRESHOLD = 40; // 80% of limit
// Track query count
queryCount++;
if (queryCount > WARN_THRESHOLD) {
console.warn(`Approaching query limit: ${queryCount}/${MAX_QUERIES}`);
}
if (queryCount > MAX_QUERIES) {
return new Response('Query limit exceeded', { status: 429 });
}
// Execute query
await env.DB.prepare('...').run();
}
};---
Best Practices
1. Batch Operations
Always batch multiple queries:
// ❌ BAD: 50 queries (hits free tier limit)
for (const user of users) {
await env.DB.prepare('INSERT INTO users VALUES (?, ?)').bind(user.name, user.email).run();
}
// ✅ GOOD: 1 batch query
const statements = users.map(u =>
env.DB.prepare('INSERT INTO users VALUES (?, ?)').bind(u.name, u.email)
);
await env.DB.batch(statements);2. Use Pagination
Avoid unbounded queries:
// ❌ BAD: Returns entire table (could be millions of rows)
const { results } = await env.DB.prepare('SELECT * FROM users').all();
// ✅ GOOD: Paginate with LIMIT and OFFSET
const page = parseInt(request.query('page') || '1');
const limit = 20;
const offset = (page - 1) * limit;
const { results } = await env.DB.prepare(
'SELECT * FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?'
).bind(limit, offset).all();3. Archive Old Data
Implement retention policy:
-- Monthly cleanup (via Cron Trigger)
DELETE FROM logs WHERE created_at < unixepoch() - 7776000; -- 90 days
PRAGMA optimize;4. Monitor Metrics
Check dashboard weekly:
- Database size trending
- Query rate spikes
- Error rate (429 errors)
5. Plan for Growth
Estimate when to upgrade:
- Query count: If regularly exceeding 40 queries/invocation → upgrade soon
- Database size: If >400 MB on free tier → plan upgrade or archival
- Database count: If >8 databases → consider consolidation or upgrade
---
Throughput Characteristics
Key Insight: Each D1 database is single-threaded and processes queries sequentially.
Maximum throughput:
- 1ms queries → ~1,000 queries/second
- 10ms queries → ~100 queries/second
- 100ms queries → ~10 queries/second
Optimization:
- Optimize queries to reduce duration (see
references/query-patterns.md) - Use read replicas for read-heavy workloads (see
references/read-replication.md) - Consider caching frequently accessed data (KV, Durable Objects)
---
References
- Official Limits Documentation: https://developers.cloudflare.com/d1/platform/limits/
- Pricing: https://developers.cloudflare.com/workers/platform/pricing/
- Query Patterns:
references/query-patterns.md - Metrics & Analytics:
references/metrics-analytics.md - Best Practices:
references/best-practices.md
---
Questions about limits?
1. Check current usage: wrangler d1 list and wrangler d1 info <database-name> 2. Review metrics dashboard for query trends 3. If approaching limits, implement batching or plan upgrade 4. For quota increase requests, contact Cloudflare support
D1 Metrics and Analytics
Purpose: Observability and performance monitoring guide for Cloudflare D1
Last Updated: 2025-01-15
---
Table of Contents
1. Overview 2. Available Metrics 3. Dashboard Access 4. GraphQL Analytics API 5. Experimental: wrangler d1 insights 6. Query Efficiency Metric 7. Performance Baselines (2025) 8. Monitoring Best Practices 9. Common Patterns 10. Integration with Observability Tools
---
Overview
Monitor D1 performance using three approaches: 1. Cloudflare Dashboard: Visual metrics with customizable time windows 2. GraphQL API: Programmatic access for custom dashboards 3. wrangler d1 insights (Experimental): Query-level performance analysis
Data Retention: All metrics retain data for 31 days
---
Available Metrics
D1 exposes 7 key metrics for database monitoring:
1. Query Performance Metrics
| Metric | Description | Use Case |
|---|---|---|
| Read Queries (QPS) | SELECT queries per second | Track read load |
| Write Queries (QPS) | INSERT/UPDATE/DELETE per second | Track write load |
| Query Latency | Response time (P50/P95/P99 percentiles) | Identify slow queries |
Query Latency is the total query response time, including:
- SQL execution time
- Result serialization time
- Network round-trip time
Percentiles Explained:
- P50 (median): 50% of queries faster than this
- P95: 95% of queries faster than this (common SLA target)
- P99: 99% of queries faster than this (tail latency)
2. Data Transfer Metrics
| Metric | Description | Use Case |
|---|---|---|
| Rows Read | Total rows scanned by queries | Detect inefficient queries |
| Rows Written | Total rows inserted/updated/deleted | Track data growth |
| Query Response Size (bytes) | Total bytes returned by queries | Monitor bandwidth usage |
Rows Read includes all scanned rows, even if not returned:
-- Scans 10,000 rows, returns 100 rows
SELECT * FROM users WHERE status = 'active'; -- Missing index
-- Rows Read: 10,000 (inefficient)
-- Rows Returned: 1003. Storage Metrics
| Metric | Description | Use Case |
|---|---|---|
| Database Size (bytes) | Current database size | Track storage usage vs limits |
Tracked hourly. Use to:
- Monitor database growth rate
- Plan for capacity (approaching 500 MB free / 10 GB paid limits)
- Estimate when to archive old data
---
Dashboard Access
Via Cloudflare Dashboard
Navigation: 1. Log in to Cloudflare dashboard 2. Navigate to Workers & Pages → D1 3. Select your database 4. Click "Metrics" tab
Features:
- Customizable time windows: 24 hours (default), 7 days, 30 days, custom range
- Interactive graphs: Hover for exact values
- Multiple databases: Switch between databases
- Real-time updates: Auto-refresh every 60 seconds
Example Metrics View:
[Graph: Read/Write QPS]
- Peak read QPS: 45
- Peak write QPS: 12
- Time: 2025-01-15 14:30 UTC
[Graph: Query Latency]
- P50: 15ms
- P95: 120ms
- P99: 450ms
[Graph: Database Size]
- Current: 380 MB
- Trend: +2 MB/dayUse Cases:
- Daily monitoring: Check P95 latency daily
- Incident investigation: Correlate latency spikes with error logs
- Capacity planning: Track database size growth
---
GraphQL Analytics API
Purpose: Programmatic access to metrics for custom dashboards, alerting, or analysis
Base URL: https://api.cloudflare.com/client/v4/graphql
Authentication: API token with Analytics:Read permission
Available Datasets
1. d1AnalyticsAdaptiveGroups: Query performance metrics (QPS, latency, rows) 2. d1StorageAdaptiveGroups: Storage metrics (database size) 3. d1QueriesAdaptiveGroups: Query-level analytics (experimental)
Example: Query Performance Metrics
query GetD1Metrics($accountId: String!, $databaseId: String!, $from: Time!, $to: Time!) {
viewer {
accounts(filter: {accountTag: $accountId}) {
d1AnalyticsAdaptiveGroups(
filter: {
databaseId: $databaseId,
datetime_geq: $from,
datetime_leq: $to
}
limit: 1000
orderBy: [datetime_ASC]
) {
dimensions {
ts: datetime
}
sum {
readQueries
writeQueries
rowsRead
rowsWritten
queryResponseBytes
}
avg {
queryLatencyMs
}
quantiles {
queryLatencyMsP50
queryLatencyMsP95
queryLatencyMsP99
}
}
}
}
}Variables:
{
"accountId": "your-account-id",
"databaseId": "your-database-id",
"from": "2025-01-01T00:00:00Z",
"to": "2025-01-31T23:59:59Z"
}Response:
{
"data": {
"viewer": {
"accounts": [{
"d1AnalyticsAdaptiveGroups": [
{
"dimensions": { "ts": "2025-01-15T14:00:00Z" },
"sum": {
"readQueries": 1250,
"writeQueries": 340,
"rowsRead": 45000,
"rowsWritten": 340,
"queryResponseBytes": 2500000
},
"avg": { "queryLatencyMs": 18 },
"quantiles": {
"queryLatencyMsP50": 12,
"queryLatencyMsP95": 85,
"queryLatencyMsP99": 220
}
}
]
}]
}
}
}Example: Storage Metrics
query GetD1Storage($accountId: String!, $databaseId: String!) {
viewer {
accounts(filter: {accountTag: $accountId}) {
d1StorageAdaptiveGroups(
filter: {databaseId: $databaseId}
limit: 100
orderBy: [datetime_DESC]
) {
dimensions {
ts: datetime
}
max {
databaseSizeBytes
}
}
}
}
}Use Cases:
- Custom dashboards: Build Grafana/Datadog dashboards
- Alerting: Trigger alerts when P95 > threshold
- Capacity planning: Analyze growth trends
- Cost optimization: Identify inefficient queries
Full GraphQL API Docs: https://developers.cloudflare.com/analytics/graphql-api/
---
Experimental: wrangler d1 insights
Status: Experimental (subject to change)
Purpose: Query-level performance analysis with actionable recommendations
Command:
wrangler d1 insights <database-name> [options]Options
| Option | Description | Example |
|---|---|---|
--slow | Show only slow queries (P95 > 200ms) | wrangler d1 insights my-db --slow |
--from | Start date (ISO 8601) | --from "2025-01-01" |
--to | End date (ISO 8601) | --to "2025-01-31" |
--limit | Max queries to display (default: 10) | --limit 20 |
Output Format
Top 10 Queries by Execution Count (Last 24 hours)
1. SELECT * FROM users WHERE email = ?
Executions: 1,200
Avg Duration: 18ms
P95 Duration: 85ms
P99 Duration: 220ms
Rows Read: 1,200
Rows Returned: 1,200
Efficiency: 1.0 (100%)
Status: ✅ Excellent
2. SELECT * FROM orders WHERE user_id = ?
Executions: 850
Avg Duration: 145ms
P95 Duration: 450ms
P99 Duration: 890ms
Rows Read: 85,000
Rows Returned: 4,250
Efficiency: 0.05 (5%)
Status: ⚠️ Needs Index
Recommendation: CREATE INDEX idx_orders_user_id ON orders(user_id);
Expected Impact: P95 450ms → ~15ms (97% improvement)
3. SELECT COUNT(*) FROM users WHERE status = ?
Executions: 600
Avg Duration: 75ms
P95 Duration: 180ms
Rows Read: 120,000
Rows Returned: 1
Efficiency: 0.0001 (<0.01%)
Status: ❌ Critical
Recommendation: CREATE INDEX idx_users_status ON users(status);
Expected Impact: P95 180ms → ~8ms (96% improvement)Understanding Output
Efficiency = rows_returned / rows_read
| Efficiency | Status | Action |
|---|---|---|
| 1.0 | ✅ Excellent | Perfect - every row read is returned |
| 0.5-0.99 | ✅ Good | Acceptable for filtered queries |
| 0.1-0.49 | ⚠️ Moderate | Consider index if query is frequent |
| < 0.1 | ❌ Poor | Add index urgently |
Use Cases
Weekly Performance Review:
# Audit slow queries weekly
wrangler d1 insights my-db --slow --from "$(date -d '7 days ago' +%Y-%m-%d)"Identify Missing Indexes:
# Find queries with efficiency < 10%
wrangler d1 insights my-db | grep "Efficiency: 0.0"Post-Deployment Validation:
# Check if new code introduced slow queries
wrangler d1 insights my-db --from "$(date +%Y-%m-%d)"Automation:
# Weekly cron job to email slow query report
#!/bin/bash
REPORT=$(wrangler d1 insights my-db --slow --from "$(date -d '7 days ago' +%Y-%m-%d)")
echo "$REPORT" | mail -s "D1 Slow Query Report" team@example.com---
Query Efficiency Metric
Definition: efficiency = rows_returned / rows_read
Why It Matters
Low efficiency means you're reading 10x-1000x more data than you need:
- Wastes database CPU
- Increases query latency
- Consumes more invocation quota
Example: Missing Index:
-- Query: Find user by email
SELECT * FROM users WHERE email = 'user@example.com';
-- Without index:
-- Rows Read: 100,000 (full table scan)
-- Rows Returned: 1
-- Efficiency: 0.00001 (0.001%)
-- Latency: 450ms
-- After: CREATE INDEX idx_users_email ON users(email);
-- Rows Read: 1 (index seek)
-- Rows Returned: 1
-- Efficiency: 1.0 (100%)
-- Latency: 8msEfficiency Targets
| Query Type | Target Efficiency | Notes |
|---|---|---|
| Primary key lookup | 1.0 | Perfect efficiency |
| Indexed WHERE clause | > 0.9 | Nearly perfect |
| Filtered query with index | > 0.1 | Acceptable |
| Full table scan | < 0.01 | Needs index |
Improving Efficiency
Step 1: Identify Low-Efficiency Queries
wrangler d1 insights my-db | grep -A 5 "Efficiency: 0\."Step 2: Explain Query Plan
wrangler d1 execute my-db --command "EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = ?"Output:
SCAN TABLE usersInterpretation: SCAN TABLE = full table scan (bad). Need index.
Step 3: Add Index
CREATE INDEX idx_users_email ON users(email);Step 4: Verify Improvement
wrangler d1 execute my-db --command "EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = ?"Output:
SEARCH TABLE users USING INDEX idx_users_email (email=?)Interpretation: SEARCH ... USING INDEX = index seek (good)
Full query optimization guide: references/query-patterns.md
---
Performance Baselines (2025)
As of January 2025: D1 performance improved 40-60% due to global optimization.
Typical Latencies (Post-Optimization)
| Query Type | P50 | P95 | P99 |
|---|---|---|---|
| Primary key lookup | < 10ms | < 20ms | < 50ms |
| Indexed WHERE clause | < 15ms | < 40ms | < 100ms |
| Simple JOIN (2 tables, indexed) | < 25ms | < 80ms | < 200ms |
| Full table scan (<10k rows) | < 50ms | < 150ms | < 400ms |
| Aggregation (COUNT, SUM) | < 30ms | < 100ms | < 250ms |
If exceeding these baselines: Check for missing indexes, inefficient queries, or database size issues.
Database Size Impact
| Database Size | Cold Start Impact | Notes |
|---|---|---|
| < 100 MB | Minimal (<50ms) | Optimal performance |
| 100-500 MB | Moderate (50-200ms) | Acceptable |
| 500 MB - 1 GB | Higher (200-500ms) | Consider archival |
| > 1 GB | Significant (500ms+) | Upgrade to paid or archive |
Cold start = first query after database hasn't been accessed in 15+ minutes.
Read Replication Performance
With read replicas (April 2025 public beta):
- Up to 2x read throughput for read-heavy workloads
- Lower latency for reads from nearest region
- See
references/read-replication.mdfor setup
---
Monitoring Best Practices
1. Set Up Alerts
Recommended alerts:
High Latency:
IF P95 query latency > 200ms for 5 minutes
THEN alert teamDatabase Size:
IF database size > 400 MB (80% of free tier limit)
THEN alert team to plan archival or upgradeError Rate:
IF D1_ERROR count > 10 in 1 minute
THEN alert teamImplementation (using GraphQL API + custom alerting):
// Fetch metrics every 5 minutes
const metrics = await fetchD1Metrics(accountId, databaseId);
if (metrics.queryLatencyMsP95 > 200) {
await sendAlert('High latency detected', { p95: metrics.queryLatencyMsP95 });
}
if (metrics.databaseSizeBytes > 400 * 1024 * 1024) { // 400 MB
await sendAlert('Approaching storage limit', { size: metrics.databaseSizeBytes });
}2. Track Efficiency Weekly
Weekly audit script:
#!/bin/bash
# weekly-d1-audit.sh
REPORT=$(wrangler d1 insights my-db --slow --from "$(date -d '7 days ago' +%Y-%m-%d)")
echo "$REPORT" > reports/d1-audit-$(date +%Y-%m-%d).txt
# Check for critical inefficiencies
if echo "$REPORT" | grep -q "Efficiency: 0.0"; then
echo "⚠️ Critical inefficiencies detected! Review report."
fiAutomate with cron:
0 9 * * 1 /path/to/weekly-d1-audit.sh3. Review Slow Queries Monthly
Monthly checklist: 1. Run wrangler d1 insights --slow 2. Identify queries with P95 > 200ms 3. Add indexes or optimize queries 4. Re-test and compare metrics
4. Correlate Metrics with Application Errors
Cross-reference:
- High query latency → Check for application timeouts
- Spike in write queries → Check for data duplication bugs
- Database size growth → Check for missing cleanup jobs
5. Baseline After Changes
After deployments: 1. Note pre-deployment P95 latency 2. Deploy changes 3. Wait 1 hour for metrics to update 4. Compare new P95 vs baseline 5. Rollback if P95 increases >50%
---
Common Patterns
High Latency Investigation
Workflow: 1. Check insights:
wrangler d1 insights my-db --slow2. Identify top slow queries (sorted by P95 latency)
3. Review query efficiency (look for < 0.1)
4. Check for missing indexes:
wrangler d1 execute my-db --command "EXPLAIN QUERY PLAN [your query]"5. Add indexes if SCAN TABLE detected:
CREATE INDEX idx_table_column ON table(column);
PRAGMA optimize;6. Verify improvement (wait 10 minutes, recheck insights)
High Row Read Count
Cause: Inefficient queries reading many rows but returning few
Solution: 1. Run wrangler d1 insights my-db → Find queries with low efficiency 2. Add indexes on filtered/joined columns 3. Use LIMIT for unbounded queries 4. Consider materialized views for complex aggregations
Example:
-- Before: Scans entire users table
SELECT COUNT(*) FROM users WHERE status = 'active';
-- Rows Read: 100,000
-- After: Add index
CREATE INDEX idx_users_status ON users(status);
-- Rows Read: 5,000 (only active users)Database Size Growth
Monitoring:
# Track size daily
wrangler d1 info my-db | grep "Size"Mitigation: 1. Archive old data:
DELETE FROM logs WHERE created_at < unixepoch() - 7776000; -- 90 days
PRAGMA optimize;2. Implement retention policy (Cron Trigger):
export default {
async scheduled(event: ScheduledEvent, env: Env) {
await env.DB.prepare(
'DELETE FROM logs WHERE created_at < ?'
).bind(Date.now() - 90 * 24 * 60 * 60 * 1000).run();
await env.DB.prepare('PRAGMA optimize').run();
}
};---
Integration with Observability Tools
Send Metrics to External Monitoring
Example: Custom metrics to Datadog:
import { D1Database } from '@cloudflare/workers-types';
export default {
async fetch(request: Request, env: Env) {
const startTime = Date.now();
let rowsRead = 0;
try {
const result = await env.DB.prepare('SELECT * FROM users WHERE status = ?')
.bind('active')
.all();
rowsRead = result.results.length;
// Send metrics to Datadog
await fetch('https://api.datadoghq.com/api/v1/series', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'DD-API-KEY': env.DATADOG_API_KEY
},
body: JSON.stringify({
series: [{
metric: 'd1.query.latency',
points: [[Math.floor(Date.now() / 1000), Date.now() - startTime]],
type: 'gauge',
tags: [`database:${env.DB_NAME}`, 'query:select_users']
}, {
metric: 'd1.rows.read',
points: [[Math.floor(Date.now() / 1000), rowsRead]],
type: 'count',
tags: [`database:${env.DB_NAME}`, 'query:select_users']
}]
})
});
return Response.json(result.results);
} catch (error) {
// Send error metric
await fetch('https://api.datadoghq.com/api/v1/series', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'DD-API-KEY': env.DATADOG_API_KEY
},
body: JSON.stringify({
series: [{
metric: 'd1.query.errors',
points: [[Math.floor(Date.now() / 1000), 1]],
type: 'count',
tags: [`database:${env.DB_NAME}`, `error:${error.message}`]
}]
})
});
throw error;
}
}
};Logging Query Performance
Worker-level logging:
async function logQuery(db: D1Database, query: string, params: any[]) {
const startTime = Date.now();
try {
const result = await db.prepare(query).bind(...params).all();
const latency = Date.now() - startTime;
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
query: query,
latency_ms: latency,
rows_returned: result.results.length,
success: true
}));
return result;
} catch (error) {
const latency = Date.now() - startTime;
console.error(JSON.stringify({
timestamp: new Date().toISOString(),
query: query,
latency_ms: latency,
error: error.message,
success: false
}));
throw error;
}
}
// Usage
const users = await logQuery(env.DB, 'SELECT * FROM users WHERE status = ?', ['active']);View logs:
wrangler tail my-worker --format pretty---
References
- Official Metrics Documentation: https://developers.cloudflare.com/d1/observability/metrics-analytics/
- GraphQL Analytics API: https://developers.cloudflare.com/analytics/graphql-api/
- Query Optimization:
references/query-patterns.md - Limits & Quotas:
references/limits.md - Best Practices:
references/best-practices.md
---
Questions about metrics?
1. View dashboard metrics (Cloudflare UI → D1 → Metrics) 2. Run insights for query-level analysis: wrangler d1 insights <database-name> 3. Check for inefficient queries (efficiency < 0.1) 4. Add indexes where needed: CREATE INDEX ... 5. Monitor weekly with automated audits
D1 Query Patterns Reference
Complete guide to all D1 Workers API methods with examples
---
Table of Contents
1. D1 API Methods Overview 2. prepare() - Prepared Statements 3. Query Result Methods 4. batch() - Multiple Queries 5. exec() - Raw SQL 6. Common Query Patterns 7. Performance Tips
---
D1 API Methods Overview
| Method | Use Case | Returns Results | Safe for User Input |
|---|---|---|---|
.prepare().bind() | Primary method for queries | Yes | ✅ Yes (prevents SQL injection) |
.batch() | Multiple queries in one round trip | Yes | ✅ Yes (if using prepare) |
.exec() | Raw SQL execution | No | ❌ No (SQL injection risk) |
---
prepare() - Prepared Statements
Primary method for all queries with user input.
Basic Syntax
const stmt = env.DB.prepare(sql);
const bound = stmt.bind(...parameters);
const result = await bound.all(); // or .first(), .run()Method Chaining (Most Common)
const result = await env.DB.prepare('SELECT * FROM users WHERE user_id = ?')
.bind(userId)
.first();Parameter Binding
// Single parameter
const user = await env.DB.prepare('SELECT * FROM users WHERE email = ?')
.bind('user@example.com')
.first();
// Multiple parameters
const posts = await env.DB.prepare(
'SELECT * FROM posts WHERE user_id = ? AND published = ? LIMIT ?'
)
.bind(userId, 1, 10)
.all();
// Use null for optional values (NEVER undefined)
const updated = await env.DB.prepare(
'UPDATE users SET bio = ?, avatar_url = ? WHERE user_id = ?'
)
.bind(bio || null, avatarUrl || null, userId)
.run();Why use prepare()?
- ✅ SQL injection protection - Parameters are safely escaped
- ✅ Performance - Query plans can be cached
- ✅ Reusability - Same statement, different parameters
- ✅ Type safety - Works with TypeScript generics
---
Query Result Methods
.all() - Get All Rows
Returns all matching rows as an array.
const { results, meta } = await env.DB.prepare('SELECT * FROM users')
.all();
console.log(results); // Array of row objects
console.log(meta); // { duration, rows_read, rows_written }With Type Safety:
interface User {
user_id: number;
email: string;
username: string;
}
const { results } = await env.DB.prepare('SELECT * FROM users')
.all<User>();
// results is now typed as User[]Response Structure:
{
success: true,
results: [
{ user_id: 1, email: 'alice@example.com', username: 'alice' },
{ user_id: 2, email: 'bob@example.com', username: 'bob' }
],
meta: {
duration: 2.5, // Milliseconds
rows_read: 2, // Rows scanned
rows_written: 0 // Rows modified
}
}---
.first() - Get First Row
Returns the first row or null if no results.
const user = await env.DB.prepare('SELECT * FROM users WHERE email = ?')
.bind('alice@example.com')
.first();
if (!user) {
return c.json({ error: 'User not found' }, 404);
}With Type Safety:
const user = await env.DB.prepare('SELECT * FROM users WHERE user_id = ?')
.bind(userId)
.first<User>();
// user is typed as User | nullNote: .first() doesn't add LIMIT 1 automatically. For better performance:
// ✅ Better: Add LIMIT 1 yourself
const user = await env.DB.prepare('SELECT * FROM users WHERE email = ? LIMIT 1')
.bind(email)
.first();---
.first(column) - Get Single Column Value
Returns the value of a specific column from the first row.
// Get count
const total = await env.DB.prepare('SELECT COUNT(*) as total FROM users')
.first('total');
console.log(total); // 42 (just the number, not an object)
// Get specific field
const email = await env.DB.prepare('SELECT email FROM users WHERE user_id = ?')
.bind(userId)
.first('email');
console.log(email); // 'user@example.com'Use Cases:
- Counting rows
- Checking existence (SELECT 1)
- Getting single values (MAX, MIN, AVG)
---
.run() - Execute Without Results
Used for INSERT, UPDATE, DELETE when you don't need the data back.
const { success, meta } = await env.DB.prepare(
'INSERT INTO users (email, username, created_at) VALUES (?, ?, ?)'
)
.bind(email, username, Date.now())
.run();
console.log(success); // true/false
console.log(meta.last_row_id); // ID of inserted row
console.log(meta.rows_written); // Number of rows affectedResponse Structure:
{
success: true,
meta: {
duration: 1.2,
rows_read: 0,
rows_written: 1,
last_row_id: 42 // Only for INSERT with AUTOINCREMENT
}
}Check if rows were affected:
const result = await env.DB.prepare('DELETE FROM users WHERE user_id = ?')
.bind(userId)
.run();
if (result.meta.rows_written === 0) {
return c.json({ error: 'User not found' }, 404);
}---
batch() - Multiple Queries
CRITICAL FOR PERFORMANCE: Execute multiple queries in one network round trip.
Basic Batch
const [users, posts, comments] = await env.DB.batch([
env.DB.prepare('SELECT * FROM users LIMIT 10'),
env.DB.prepare('SELECT * FROM posts LIMIT 10'),
env.DB.prepare('SELECT * FROM comments LIMIT 10')
]);
console.log(users.results); // User rows
console.log(posts.results); // Post rows
console.log(comments.results); // Comment rowsBatch with Parameters
const stmt1 = env.DB.prepare('SELECT * FROM users WHERE user_id = ?').bind(1);
const stmt2 = env.DB.prepare('SELECT * FROM users WHERE user_id = ?').bind(2);
const stmt3 = env.DB.prepare('SELECT * FROM posts WHERE user_id = ?').bind(1);
const results = await env.DB.batch([stmt1, stmt2, stmt3]);Bulk Insert with Batch
const users = [
{ email: 'user1@example.com', username: 'user1' },
{ email: 'user2@example.com', username: 'user2' },
{ email: 'user3@example.com', username: 'user3' }
];
const inserts = users.map(u =>
env.DB.prepare('INSERT INTO users (email, username, created_at) VALUES (?, ?, ?)')
.bind(u.email, u.username, Date.now())
);
const results = await env.DB.batch(inserts);
const successCount = results.filter(r => r.success).length;
console.log(`Inserted ${successCount} users`);Transaction-like Behavior
// All statements execute sequentially
// If one fails, remaining statements don't execute
await env.DB.batch([
// Deduct credits from user 1
env.DB.prepare('UPDATE users SET credits = credits - ? WHERE user_id = ?')
.bind(100, userId1),
// Add credits to user 2
env.DB.prepare('UPDATE users SET credits = credits + ? WHERE user_id = ?')
.bind(100, userId2),
// Record transaction
env.DB.prepare('INSERT INTO transactions (from_user, to_user, amount) VALUES (?, ?, ?)')
.bind(userId1, userId2, 100)
]);Batch Behavior:
- Executes statements sequentially (in order)
- Each statement commits individually (auto-commit mode)
- If one fails, remaining statements don't execute
- All statements in one network round trip (huge performance win)
Batch Performance Comparison
// ❌ BAD: 10 separate queries = 10 network round trips
for (let i = 0; i < 10; i++) {
await env.DB.prepare('SELECT * FROM users WHERE user_id = ?')
.bind(i)
.first();
}
// ~500ms total latency
// ✅ GOOD: 1 batch query = 1 network round trip
const userIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const queries = userIds.map(id =>
env.DB.prepare('SELECT * FROM users WHERE user_id = ?').bind(id)
);
const results = await env.DB.batch(queries);
// ~50ms total latency---
exec() - Raw SQL
AVOID IN PRODUCTION. Only use for migrations and one-off tasks.
Basic Exec
const result = await env.DB.exec('SELECT * FROM users');
console.log(result);
// { count: 1, duration: 2.5 }NOTE: exec() does not return data, only count and duration!
Multiple Statements
const result = await env.DB.exec(`
DROP TABLE IF EXISTS temp_users;
CREATE TABLE temp_users (user_id INTEGER PRIMARY KEY);
INSERT INTO temp_users VALUES (1), (2), (3);
`);
console.log(result);
// { count: 3, duration: 5.2 }⚠️ NEVER Use exec() For:
// ❌ NEVER: SQL injection vulnerability
const email = userInput;
await env.DB.exec(`SELECT * FROM users WHERE email = '${email}'`);
// ✅ ALWAYS: Use prepared statements instead
await env.DB.prepare('SELECT * FROM users WHERE email = ?')
.bind(email)
.first();✅ ONLY Use exec() For:
- Running migration files locally
- One-off maintenance tasks (PRAGMA optimize)
- Database initialization scripts
- CLI tools (not production Workers)
---
Common Query Patterns
Existence Check
// Check if email exists
const exists = await env.DB.prepare('SELECT 1 FROM users WHERE email = ? LIMIT 1')
.bind(email)
.first();
if (exists) {
return c.json({ error: 'Email already registered' }, 409);
}Get or Create
// Try to find user
let user = await env.DB.prepare('SELECT * FROM users WHERE email = ?')
.bind(email)
.first<User>();
// Create if doesn't exist
if (!user) {
const result = await env.DB.prepare(
'INSERT INTO users (email, username, created_at) VALUES (?, ?, ?)'
)
.bind(email, username, Date.now())
.run();
const userId = result.meta.last_row_id;
user = await env.DB.prepare('SELECT * FROM users WHERE user_id = ?')
.bind(userId)
.first<User>();
}Pagination
const page = 1;
const limit = 20;
const offset = (page - 1) * limit;
const [countResult, dataResult] = await env.DB.batch([
env.DB.prepare('SELECT COUNT(*) as total FROM posts WHERE published = 1'),
env.DB.prepare(
'SELECT * FROM posts WHERE published = 1 ORDER BY created_at DESC LIMIT ? OFFSET ?'
).bind(limit, offset)
]);
const total = countResult.results[0].total;
const posts = dataResult.results;
return {
posts,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
};Upsert (INSERT or UPDATE)
// SQLite 3.24.0+ supports UPSERT
await env.DB.prepare(`
INSERT INTO user_settings (user_id, theme, language)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
theme = excluded.theme,
language = excluded.language,
updated_at = unixepoch()
`)
.bind(userId, theme, language)
.run();Bulk Upsert
const settings = [
{ user_id: 1, theme: 'dark', language: 'en' },
{ user_id: 2, theme: 'light', language: 'es' }
];
const upserts = settings.map(s =>
env.DB.prepare(`
INSERT INTO user_settings (user_id, theme, language)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
theme = excluded.theme,
language = excluded.language
`).bind(s.user_id, s.theme, s.language)
);
await env.DB.batch(upserts);---
Performance Tips
Use SELECT Column Names (Not SELECT *)
// ❌ Bad: Fetches all columns
const users = await env.DB.prepare('SELECT * FROM users').all();
// ✅ Good: Only fetch needed columns
const users = await env.DB.prepare('SELECT user_id, email, username FROM users').all();Always Use LIMIT
// ❌ Bad: Could return millions of rows
const posts = await env.DB.prepare('SELECT * FROM posts').all();
// ✅ Good: Limit result set
const posts = await env.DB.prepare('SELECT * FROM posts LIMIT 100').all();Use Indexes
-- Create index for common queries
CREATE INDEX IF NOT EXISTS idx_posts_published_created
ON posts(published, created_at DESC)
WHERE published = 1;// Query will use the index
const posts = await env.DB.prepare(
'SELECT * FROM posts WHERE published = 1 ORDER BY created_at DESC LIMIT 10'
).all();Check Index Usage
EXPLAIN QUERY PLAN SELECT * FROM posts WHERE published = 1;
-- Should see: SEARCH posts USING INDEX idx_posts_published_createdBatch Instead of Loop
// ❌ Bad: Multiple network round trips
for (const id of userIds) {
const user = await env.DB.prepare('SELECT * FROM users WHERE user_id = ?')
.bind(id)
.first();
}
// ✅ Good: One network round trip
const queries = userIds.map(id =>
env.DB.prepare('SELECT * FROM users WHERE user_id = ?').bind(id)
);
const results = await env.DB.batch(queries);---
Query Efficiency Analysis (2025)
Available: Post-January 2025 Performance Update Status: Experimental (wrangler d1 insights command)
What Is Query Efficiency?
Query efficiency measures how effectively your queries use database resources. It's calculated as the ratio of rows returned to rows scanned:
efficiency = rows_returned / rows_readEfficiency Values:
- 1.0 (100%) = Perfect - every row scanned is returned
- 0.5 (50%) = Good - half the scanned rows are useful
- 0.1 (10%) = Poor - reading 10x more data than needed
- < 0.01 (1%) = Critical - needs immediate index optimization
Measuring Query Efficiency
Every D1 query returns metadata with rows_read and results count:
const result = await env.DB.prepare('SELECT * FROM posts WHERE user_id = ?')
.bind(userId)
.all();
const efficiency = result.results.length / result.meta.rows_read;
console.log({
rowsReturned: result.results.length, // 50 posts
rowsRead: result.meta.rows_read, // 100,000 (full table scan!)
efficiency: efficiency.toFixed(4), // 0.0005 (0.05% - CRITICAL)
duration: result.meta.duration // 450ms (slow!)
});Red Flags:
- Efficiency < 0.1 (10%) → Add index immediately
- Efficiency < 0.01 (1%) → Critical performance issue
rows_read>>rows_returned→ Missing or unused index
Using wrangler d1 insights (Experimental)
Command:
wrangler d1 insights my-databaseWhat It Shows:
- Slow queries (P95 latency > 200ms)
- Inefficient queries (efficiency < 0.1)
- Query frequency (executions/day)
- Suggested indexes
Example Output:
Top 5 Slow Queries (by P95 latency):
1. SELECT * FROM orders WHERE user_id = ?
Executions: 850/day
P50: 180ms | P95: 450ms | P99: 850ms
Efficiency: 0.05 (5%)
Rows Read: 100,000 | Rows Returned: ~50
⚠️ SUGGESTION: CREATE INDEX idx_orders_user_id ON orders(user_id);
2. SELECT COUNT(*) FROM users WHERE status = 'active'
Executions: 600/day
P50: 90ms | P95: 180ms | P99: 350ms
Efficiency: 0.0001 (<0.01%)
Rows Read: 120,000 | Rows Returned: 1
⚠️ SUGGESTION: CREATE INDEX idx_users_status ON users(status);Flags Available:
# Show only slow queries (P95 > 200ms)
wrangler d1 insights my-database --slow
# Show queries with low efficiency (< 0.1)
wrangler d1 insights my-database --inefficient
# Limit results
wrangler d1 insights my-database --limit 10EXPLAIN QUERY PLAN Analysis
Use EXPLAIN to understand WHY a query is inefficient:
EXPLAIN QUERY PLAN SELECT * FROM posts WHERE user_id = 123;Without Index (Inefficient):
SCAN TABLE posts- Scans entire table (all rows)
- Efficiency: rows_returned / total_rows (typically < 0.01)
With Index (Efficient):
SEARCH TABLE posts USING INDEX idx_posts_user_id (user_id=?)- Uses index to find matching rows
- Efficiency: ~1.0 (only scans matching rows)
Before/After Index Optimization
Before Index (Inefficient)
// Query without index
const result = await env.DB.prepare('SELECT * FROM orders WHERE user_id = ?')
.bind(userId)
.all();
console.log({
rowsReturned: 50,
rowsRead: 100000, // Full table scan
efficiency: 0.0005, // 0.05% - CRITICAL
duration: 450 // 450ms - SLOW
});EXPLAIN QUERY PLAN:
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE user_id = ?;
-- Output: SCAN TABLE ordersAfter Index (Efficient)
-- Create index
CREATE INDEX idx_orders_user_id ON orders(user_id);
PRAGMA optimize;// Same query, now using index
const result = await env.DB.prepare('SELECT * FROM orders WHERE user_id = ?')
.bind(userId)
.all();
console.log({
rowsReturned: 50,
rowsRead: 50, // Index seek (exact match)
efficiency: 1.0, // 100% - PERFECT
duration: 15 // 15ms - FAST (97% improvement!)
});EXPLAIN QUERY PLAN:
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE user_id = ?;
-- Output: SEARCH TABLE orders USING INDEX idx_orders_user_id (user_id=?)Efficiency Optimization Workflow
Step 1: Identify Inefficient Queries
// Monitor all queries
app.use('*', async (c, next) => {
const start = Date.now();
await next();
// Log slow or inefficient queries
if (c.get('queryMeta')) {
const meta = c.get('queryMeta');
const efficiency = meta.rowsReturned / meta.rows_read;
if (efficiency < 0.1 || meta.duration > 100) {
console.warn({
endpoint: c.req.path,
efficiency: efficiency.toFixed(4),
duration: meta.duration,
rowsRead: meta.rows_read,
rowsReturned: meta.rowsReturned
});
}
}
});Step 2: Run EXPLAIN QUERY PLAN
# Check if index is being used
wrangler d1 execute my-database --command \
"EXPLAIN QUERY PLAN SELECT * FROM orders WHERE user_id = 123"Step 3: Create Index
# Add missing index
wrangler d1 execute my-database --command \
"CREATE INDEX idx_orders_user_id ON orders(user_id); PRAGMA optimize;"Step 4: Verify Improvement
# Re-run EXPLAIN to confirm index usage
wrangler d1 execute my-database --command \
"EXPLAIN QUERY PLAN SELECT * FROM orders WHERE user_id = 123"
# Should now show: SEARCH TABLE orders USING INDEX idx_orders_user_idComposite Index Optimization
For queries with multiple filters or ORDER BY:
// Query with WHERE + ORDER BY
const posts = await env.DB.prepare(
'SELECT * FROM posts WHERE user_id = ? ORDER BY created_at DESC LIMIT 10'
).bind(userId).all();
// Without composite index:
// Efficiency: 0.08 (8%), Duration: 220ms
// rows_read: 50,000, rows_returned: 10Create Composite Index:
-- Index covers both WHERE and ORDER BY
CREATE INDEX idx_posts_user_created
ON posts(user_id, created_at DESC);
PRAGMA optimize;EXPLAIN Analysis:
EXPLAIN QUERY PLAN
SELECT * FROM posts
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 10;
-- Before: SCAN TABLE posts
-- USING TEMP B-TREE FOR ORDER BY
-- After: SEARCH TABLE posts USING INDEX idx_posts_user_created (user_id=?)Result:
// With composite index:
// Efficiency: 1.0 (100%), Duration: 12ms
// rows_read: 10, rows_returned: 10
// 95% faster!Efficiency Best Practices
✅ DO:
- Monitor query efficiency in production
- Use
wrangler d1 insightsto find slow queries - Run EXPLAIN QUERY PLAN before adding indexes
- Create indexes on WHERE, JOIN, and ORDER BY columns
- Use composite indexes for multi-column queries
- Run
PRAGMA optimizeafter creating indexes
❌ DON'T:
- Ignore queries with efficiency < 0.1
- Add indexes without measuring impact
- Create too many indexes (slows writes)
- Use SELECT * without LIMIT
- Skip EXPLAIN QUERY PLAN analysis
Efficiency Targets (Post-2025 Baselines)
Based on Cloudflare's January 2025 performance update:
| Query Type | Target Efficiency | Target P95 Latency |
|---|---|---|
| Primary key lookup | > 0.95 | < 20ms |
| Indexed WHERE | > 0.80 | < 40ms |
| Indexed JOIN | > 0.50 | < 80ms |
| Aggregation (COUNT, SUM) | N/A | < 100ms |
If queries fall below these targets, investigate with EXPLAIN QUERY PLAN.
---
Meta Object Reference
Every D1 query returns a meta object with execution details:
{
duration: 2.5, // Query execution time in milliseconds
rows_read: 100, // Number of rows scanned
rows_written: 1, // Number of rows modified (INSERT/UPDATE/DELETE)
last_row_id: 42, // ID of last inserted row (INSERT only)
changed: 1 // Rows affected (UPDATE/DELETE only)
}Using Meta for Debugging
const result = await env.DB.prepare('SELECT * FROM large_table WHERE status = ?')
.bind('active')
.all();
console.log(`Query took ${result.meta.duration}ms`);
console.log(`Scanned ${result.meta.rows_read} rows`);
console.log(`Returned ${result.results.length} rows`);
// If rows_read is much higher than results.length, add an index!
if (result.meta.rows_read > result.results.length * 10) {
console.warn('Query is inefficient - consider adding an index');
}---
Official Documentation
- Workers API: https://developers.cloudflare.com/d1/worker-api/
- Prepared Statements: https://developers.cloudflare.com/d1/worker-api/prepared-statements/
- Return Object: https://developers.cloudflare.com/d1/worker-api/return-object/
Cloudflare D1 Complete Setup Guide
Complete setup for Cloudflare D1 serverless SQLite database.
---
Step 1: Create D1 Database
npx wrangler d1 create my-databaseOutput:
✅ Successfully created DB 'my-database'
[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "<UUID>"Save the database_id!
---
Step 2: Configure D1 Binding
Add to wrangler.jsonc:
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-11",
"d1_databases": [
{
"binding": "DB", // env.DB in code
"database_name": "my-database", // From Step 1
"database_id": "<UUID>", // From Step 1
"preview_database_id": "local-db" // For local dev
}
]
}---
Step 3: Create Migration
npx wrangler d1 migrations create my-database create_users_tableCreates: migrations/0001_create_users_table.sql
---
Step 4: Write Schema
Edit migrations/0001_create_users_table.sql:
DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
PRAGMA optimize;---
Step 5: Apply Migration
Local:
npx wrangler d1 migrations apply my-database --localProduction:
npx wrangler d1 migrations apply my-database --remote---
Step 6: Query from Worker
import { Hono } from 'hono';
type Bindings = {
DB: D1Database;
};
const app = new Hono<{ Bindings: Bindings }>();
app.get('/users/:email', async (c) => {
const email = c.req.param('email');
const { results } = await c.env.DB.prepare(
'SELECT * FROM users WHERE email = ?'
)
.bind(email)
.all();
return c.json(results);
});
app.post('/users', async (c) => {
const { email, username } = await c.req.json();
const { results } = await c.env.DB.prepare(
'INSERT INTO users (email, username, created_at) VALUES (?, ?, ?) RETURNING *'
)
.bind(email, username, Date.now())
.all();
return c.json(results[0]);
});
export default app;---
Step 7: Deploy
npx wrangler deployTest:
curl https://my-worker.workers.dev/users/test@example.com---
Production Checklist
- [ ] Database created with descriptive name
- [ ] Bindings configured in wrangler.jsonc
- [ ] Migrations applied to production
- [ ] Indexes created for common queries
- [ ] Prepared statements used (never string concatenation)
- [ ] Error handling implemented
- [ ] Read replication configured (if needed)
- [ ] Batch queries for multiple operations
- [ ] Schema optimized with PRAGMA optimize
- [ ] Database ID stored securely (not in public repos)
---
Load `references/query-patterns.md` for advanced query patterns. Load `references/read-replication.md` for global read replicas. Load `references/best-practices.md` for optimization techniques.
#!/bin/bash
#
# Cloudflare D1 Setup and Migration Workflow
#
# This script demonstrates the complete D1 workflow:
# 1. Create a D1 database
# 2. Configure bindings
# 3. Create and apply migrations
# 4. Query the database
#
# Usage:
# chmod +x d1-setup-migration.sh
# ./d1-setup-migration.sh my-app-database
#
set -e # Exit on error
DATABASE_NAME="${1:-my-database}"
echo "========================================="
echo "Cloudflare D1 Setup and Migration"
echo "========================================="
echo ""
# Step 1: Create D1 Database
echo "📦 Step 1: Creating D1 database '$DATABASE_NAME'..."
echo ""
npx wrangler d1 create "$DATABASE_NAME"
echo ""
echo "✅ Database created!"
echo ""
echo "📝 IMPORTANT: Copy the output above and add to your wrangler.jsonc:"
echo ""
echo ' {
"d1_databases": [
{
"binding": "DB",
"database_name": "'"$DATABASE_NAME"'",
"database_id": "<UUID_FROM_OUTPUT_ABOVE>",
"preview_database_id": "local-dev-db"
}
]
}'
echo ""
read -p "Press ENTER when you've added the binding to wrangler.jsonc..."
# Step 2: Create Migrations Directory
echo ""
echo "📁 Step 2: Setting up migrations directory..."
mkdir -p migrations
# Step 3: Create Initial Migration
echo ""
echo "🔨 Step 3: Creating initial migration..."
echo ""
npx wrangler d1 migrations create "$DATABASE_NAME" create_initial_schema
# Find the created migration file (most recent .sql file in migrations/)
MIGRATION_FILE=$(ls -t migrations/*.sql | head -n1)
echo ""
echo "✅ Migration file created: $MIGRATION_FILE"
echo ""
echo "📝 Add your schema to this file. Example:"
echo ""
echo " DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
PRAGMA optimize;"
echo ""
read -p "Press ENTER when you've edited the migration file..."
# Step 4: Apply Migration Locally
echo ""
echo "🔧 Step 4: Applying migration to LOCAL database..."
echo ""
npx wrangler d1 migrations apply "$DATABASE_NAME" --local
echo ""
echo "✅ Local migration applied!"
# Step 5: Verify Local Database
echo ""
echo "🔍 Step 5: Verifying local database..."
echo ""
npx wrangler d1 execute "$DATABASE_NAME" --local --command "SELECT name FROM sqlite_master WHERE type='table'"
# Step 6: Seed Local Database (Optional)
echo ""
echo "🌱 Step 6: Would you like to seed the local database with test data?"
read -p "Seed database? (y/n): " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Creating seed data..."
cat > seed.sql << 'EOF'
-- Seed data for testing
INSERT INTO users (email, username, created_at) VALUES
('alice@example.com', 'alice', unixepoch()),
('bob@example.com', 'bob', unixepoch()),
('charlie@example.com', 'charlie', unixepoch());
EOF
npx wrangler d1 execute "$DATABASE_NAME" --local --file=seed.sql
echo ""
echo "✅ Seed data inserted!"
echo ""
echo "🔍 Verifying data..."
npx wrangler d1 execute "$DATABASE_NAME" --local --command "SELECT * FROM users"
fi
# Step 7: Apply to Production (Optional)
echo ""
echo "🚀 Step 7: Ready to apply migration to PRODUCTION?"
echo ""
echo "⚠️ WARNING: This will modify your production database!"
read -p "Apply to production? (y/n): " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Applying migration to production..."
npx wrangler d1 migrations apply "$DATABASE_NAME" --remote
echo ""
echo "✅ Production migration applied!"
else
echo "Skipping production migration."
echo ""
echo "To apply later, run:"
echo " npx wrangler d1 migrations apply $DATABASE_NAME --remote"
fi
# Summary
echo ""
echo "========================================="
echo "✅ D1 Setup Complete!"
echo "========================================="
echo ""
echo "Database: $DATABASE_NAME"
echo "Local database: ✅"
echo "Migrations: ✅"
echo ""
echo "📚 Next steps:"
echo ""
echo "1. Start dev server:"
echo " npm run dev"
echo ""
echo "2. Query from your Worker:"
echo ' const user = await env.DB.prepare("SELECT * FROM users WHERE email = ?")
.bind("alice@example.com")
.first();'
echo ""
echo "3. Create more migrations as needed:"
echo " npx wrangler d1 migrations create $DATABASE_NAME <migration_name>"
echo ""
echo "4. View all tables:"
echo " npx wrangler d1 execute $DATABASE_NAME --local --command \"SELECT name FROM sqlite_master WHERE type='table'\""
echo ""
echo "========================================="