Introduction
Serverless computing has transformed how developers build and deploy applications, but the database layer has historically been the bottleneck. Traditional databases require persistent connections, connection pooling, and careful capacity planning—all of which conflict with the ephemeral nature of serverless functions. Cloudflare D1 addresses this by bringing SQLite to the edge as a fully managed serverless database.
D1 is Cloudflare's serverless database built on SQLite, designed to run at the edge alongside Cloudflare Workers. It offers sub-millisecond read latency for data stored at the edge, automatic scaling, and a familiar SQL interface that works with standard SQLite tooling. Unlike traditional databases that run in a single region, D1 replicates data across Cloudflare's global network of over 300 data centers, bringing your data closer to your users.
The combination of D1 and Workers creates a powerful platform for building globally distributed applications with zero cold starts, no connection pooling headaches, and pay-per-query pricing. This guide covers everything you need to know to build production applications with D1, from basic setup to advanced patterns like read replicas and data migration.
Understanding D1: Core Concepts
D1 is built on SQLite, the most widely deployed database engine in the world. SQLite's simplicity—everything in a single file, zero configuration, and a rich SQL dialect—makes it an excellent foundation for a serverless database. D1 extends SQLite with Cloudflare's global replication infrastructure, ensuring that data is available at the edge with minimal latency.
The serverless model means you don't manage servers, connections, or capacity. D1 automatically handles replication, backups, and scaling. You pay for the number of reads and writes, not for idle server time. This makes D1 cost-effective for applications with variable traffic patterns.
D1 uses a primary-replica architecture. Writes go to the primary database and are replicated to read replicas across Cloudflare's network. Reads can be served from the nearest replica, providing sub-millisecond latency for most queries. The replication is eventually consistent—there may be a brief delay between a write and when it's visible on read replicas—but this is typically under 100ms.
The integration with Workers is seamless. Workers can access D1 through a binding, which provides type-safe access to the database without managing connection strings or authentication tokens. The binding handles connection management, request routing, and error handling automatically.
Architecture and Design Patterns
D1 and Workers Integration
The primary pattern for using D1 is through Worker bindings. A binding creates a direct connection between your Worker and a D1 database, providing methods for executing queries, prepared statements, and batch operations.
// wrangler.toml
// [[d1_databases]]
// binding = "DB"
// database_name = "my-app-db"
// database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
// worker.ts
interface Env {
DB: D1Database
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
if (url.pathname === '/api/users') {
const users = await env.DB.prepare(
'SELECT id, name, email FROM users ORDER BY created_at DESC LIMIT 50'
).all()
return Response.json(users.results)
}
if (url.pathname === '/api/users' && request.method === 'POST') {
const body = await request.json()
const result = await env.DB.prepare(
'INSERT INTO users (name, email) VALUES (?, ?) RETURNING id, name, email'
).bind(body.name, body.email).first()
return Response.json(result, { status: 201 })
}
return new Response('Not Found', { status: 404 })
}
}Data Modeling for Edge
D1 works best with denormalized data models that minimize the number of queries needed per request. Since D1 is SQLite-based, it supports JSON columns, full-text search (FTS5), and complex queries, but the edge latency model favors simple, fast queries.
// Schema design for D1
async function setupSchema(env: Env) {
await env.DB.exec(`
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
content TEXT NOT NULL,
author_id INTEGER NOT NULL,
tags TEXT, -- JSON array stored as text
published_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_posts_slug ON posts(slug);
CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_posts_published ON posts(published_at DESC);
-- Full-text search index
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
title, content, content=posts, content_rowid=id
);
-- Triggers to keep FTS index in sync
CREATE TRIGGER posts_ai AFTER INSERT ON posts BEGIN
INSERT INTO posts_fts(rowid, title, content) VALUES (new.id, new.title, new.content);
END;
`)
}Batch Operations
D1 supports batch operations that execute multiple statements in a single request, reducing round trips and ensuring atomicity.
// Batch operations for data seeding or migrations
async function seedData(env: Env) {
const batch = [
env.DB.prepare('INSERT INTO categories (name, slug) VALUES (?, ?)').bind('Technology', 'technology'),
env.DB.prepare('INSERT INTO categories (name, slug) VALUES (?, ?)').bind('Science', 'science'),
env.DB.prepare('INSERT INTO categories (name, slug) VALUES (?, ?)').bind('Design', 'design'),
]
const results = await env.DB.batch(batch)
return results
}Step-by-Step Implementation
Setting Up D1 with Wrangler
# Install Wrangler CLI
npm install -g wrangler
# Create a new D1 database
wrangler d1 create my-app-db
# Run a migration file
wrangler d1 execute my-app-db --file=./migrations/0001_create_users.sql
# Execute a query against the database
wrangler d1 execute my-app-db --command "SELECT * FROM users LIMIT 10"Building a Full API with D1
// src/index.ts
interface Env {
DB: D1Database
}
interface User {
id: number
name: string
email: string
created_at: string
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const router = new Router()
router.get('/api/users', async () => {
const { results } = await env.DB.prepare(
'SELECT id, name, email, created_at FROM users ORDER BY created_at DESC'
).all<User>()
return Response.json(results)
})
router.get('/api/users/:id', async (params) => {
const user = await env.DB.prepare(
'SELECT id, name, email, created_at FROM users WHERE id = ?'
).bind(params.id).first<User>()
if (!user) return new Response('Not Found', { status: 404 })
return Response.json(user)
})
router.post('/api/users', async (params, request) => {
const body = await request.json() as { name: string; email: string }
const existing = await env.DB.prepare(
'SELECT id FROM users WHERE email = ?'
).bind(body.email).first()
if (existing) {
return Response.json({ error: 'Email already exists' }, { status: 409 })
}
const result = await env.DB.prepare(
'INSERT INTO users (name, email) VALUES (?, ?) RETURNING id, name, email, created_at'
).bind(body.name, body.email).first<User>()
return Response.json(result, { status: 201 })
})
return router.handle(request)
}
}Full-Text Search with D1
// Search implementation using FTS5
async function searchPosts(env: Env, query: string, page: number = 1) {
const offset = (page - 1) * 20
const { results } = await env.DB.prepare(`
SELECT p.id, p.title, p.slug, p.published_at,
snippet(posts_fts, 1, '<mark>', '</mark>', '...', 32) as excerpt,
rank
FROM posts_fts
JOIN posts p ON posts_fts.rowid = p.id
WHERE posts_fts MATCH ?
ORDER BY rank
LIMIT 20 OFFSET ?
`).bind(query, offset).all()
return results
}Connecting D1 to a Next.js App
// app/api/posts/route.ts (Next.js App Router)
import { getCloudflareContext } from '@cloudflare/next-on-pages'
export async function GET() {
const { env } = getCloudflareContext()
const { results } = await env.DB.prepare(
'SELECT * FROM posts WHERE published_at IS NOT NULL ORDER BY published_at DESC LIMIT 20'
).all()
return Response.json(results)
}Real-World Use Cases
Blog and Content Platform
D1 is ideal for blogs and content platforms where read traffic significantly outweighs write traffic. The edge-replicated read replicas serve content with sub-millisecond latency, and the FTS5 extension provides full-text search without an external search service.
API Configuration Store
Store API configurations, feature flags, and rate limit rules in D1 for fast access at the edge. Workers can read configuration on every request without adding latency, enabling real-time configuration changes without redeployment.
Session and User Data
Store session data and user preferences in D1 for fast access by Workers handling authentication and personalization. The edge latency ensures that session validation doesn't add noticeable delay to request processing.
E-Commerce Product Catalog
Store product catalogs, inventory levels, and pricing in D1 for fast product lookups at the edge. Use batch operations to update inventory during checkout and read replicas to serve product pages with minimal latency.
Best Practices for Production
-
Use prepared statements: Always use parameterized queries with
.bind()instead of string interpolation. This prevents SQL injection and enables query plan caching. -
Design for single-region writes: D1's primary is in a single region. Design your write patterns to minimize cross-region write latency by batching writes or using eventually consistent patterns.
-
Leverage FTS5 for search: D1 includes SQLite's FTS5 extension for full-text search. Use it instead of an external search service for text search workloads that fit within D1's capabilities.
-
Use batch operations: Group related operations into batches to reduce round trips. D1's batch API executes multiple statements atomically.
-
Implement proper error handling: D1 may return errors for constraint violations, syntax errors, or resource limits. Handle these gracefully in your Workers.
-
Monitor query performance: Use D1's built-in query analytics to identify slow queries and optimize them with appropriate indexes.
-
Use migrations for schema changes: Manage schema changes through migration files executed with Wrangler. This ensures consistent schema across environments.
-
Cache frequently accessed data: Use Workers KV or Cache API alongside D1 for frequently accessed, rarely changing data to reduce D1 read costs.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Cross-region write latency | Slow writes for distant users | Batch writes, use eventually consistent patterns |
| Missing indexes on query columns | Full table scans, slow queries | Create indexes for frequently queried columns |
| String interpolation in queries | SQL injection vulnerabilities | Always use .bind() for parameterized queries |
| Ignoring replication lag | Stale reads after writes | Read from primary after writes, or accept eventual consistency |
| Not using batch operations | Excessive round trips | Group related operations into batches |
| Storing large blobs in D1 | Slow queries, storage costs | Use R2 for file storage, store references in D1 |
| Not monitoring D1 limits | Rate limiting, errors | Monitor read/write counts and storage usage |
| Using D1 for high-write workloads | Write contention, performance issues | Consider other databases for write-heavy patterns |
Performance Optimization
D1 performance depends on query design, index usage, and data model. Use EXPLAIN QUERY PLAN to analyze query performance and create indexes for frequently accessed columns.
// Analyze query performance
async function analyzeQuery(env: Env) {
const plan = await env.DB.prepare(
'EXPLAIN QUERY PLAN SELECT * FROM posts WHERE author_id = ? ORDER BY published_at DESC'
).bind(1).all()
console.log('Query plan:', plan.results)
}
// Use covering indexes to avoid table lookups
async function createOptimalIndexes(env: Env) {
await env.DB.exec(`
CREATE INDEX idx_posts_author_published
ON posts(author_id, published_at DESC)
INCLUDE (title, slug);
`)
}Use the RETURNING clause to avoid additional SELECT queries after INSERT or UPDATE operations. This reduces round trips and improves write operation latency.
Comparison with Alternatives
| Feature | Cloudflare D1 | PlanetScale Neon | Turso (libSQL) | Supabase |
|---|---|---|---|---|
| Engine | SQLite | MySQL | SQLite (libSQL) | PostgreSQL |
| Edge Replication | Yes (300+ locations) | No | Yes | No |
| Serverless | Yes | Yes | Yes | Partial |
| Connection Model | Worker binding | HTTP/WebSocket | HTTP | Connection pool |
| Full-Text Search | FTS5 | External | FTS5 | Built-in |
| Free Tier | 5M reads/day, 100K writes/day | 5 GB storage | 500M reads/month | 500 MB |
| Pricing | Per read/write | Per row read/written | Per read/write | Per compute hour |
Advanced Patterns
D1 with Hyperdrive
For applications that need to access D1 from outside Cloudflare Workers, use Hyperdrive to provide connection pooling and caching.
Read-After-Write Consistency
Implement read-after-write consistency by reading from the primary after a write operation.
async function createAndFetch(env: Env, name: string, email: string) {
// Write to primary
const created = await env.DB.prepare(
'INSERT INTO users (name, email) VALUES (?, ?) RETURNING id, name, email'
).bind(name, email).first()
// Read immediately (may hit primary for consistency)
const user = await env.DB.prepare(
'SELECT id, name, email, created_at FROM users WHERE id = ?'
).bind(created.id).first()
return user
}Data Export and Backup
Use Wrangler to export D1 data for backup or migration to other databases.
# Export data as SQL
wrangler d1 export my-app-db --output=./backup.sql
# Export specific tables
wrangler d1 export my-app-db --table=users --output=./users.sqlTesting Strategies
Test D1 Workers locally using Wrangler's local development mode, which creates an in-memory SQLite database that mirrors D1's behavior.
// test/worker.test.ts
import { unstable_dev } from 'wrangler'
describe('D1 Worker', () => {
let worker: any
beforeAll(async () => {
worker = await unstable_dev('src/index.ts', {
experimental: { disableExperimentalWarning: true }
})
})
afterAll(async () => {
await worker.stop()
})
test('should create and retrieve a user', async () => {
const createRes = await worker.fetch('/api/users', {
method: 'POST',
body: JSON.stringify({ name: 'Test User', email: 'test@example.com' }),
headers: { 'Content-Type': 'application/json' }
})
expect(createRes.status).toBe(201)
const user = await createRes.json()
expect(user.name).toBe('Test User')
const getRes = await worker.fetch(`/api/users/${user.id}`)
expect(getRes.status).toBe(200)
})
})Future Outlook
Cloudflare is actively developing D1 with features like point-in-time recovery, global read replicas with stronger consistency guarantees, and improved query performance. The platform is also exploring integration with AI workloads, allowing vector search and embeddings storage directly in D1.
The serverless database space is growing rapidly, with D1 positioned as the edge-native option. As Cloudflare's network continues to expand, D1's latency advantages will increase, making it an increasingly compelling choice for globally distributed applications.
D1 Database Limits and Scaling
Cloudflare D1 has specific limits that affect application design. Each database has a maximum size of 10 GB, individual queries have a 10-second timeout, and batch operations are limited to 100 statements per transaction. For applications that outgrow these limits, consider sharding data across multiple D1 databases or using D1 for read-heavy workloads while routing write-heavy workloads to a traditional database. Monitor query performance using D1's built-in analytics dashboard and optimize slow queries by adding appropriate indexes.
D1 Data Modeling Strategies
Design D1 database schemas with the edge computing paradigm in mind. Prefer denormalized data structures that reduce the number of queries needed per request, since each query incurs round-trip latency to the storage backend. Use composite indexes that cover common query patterns to minimize full table scans. Implement soft deletes using a deleted_at column instead of actually deleting rows, which simplifies data recovery and audit trails. Use SQLite-specific features like JSON functions for semi-structured data that doesn't fit neatly into relational columns.
Conclusion
Cloudflare D1 brings SQLite to the edge as a fully managed serverless database. Its integration with Workers, global replication, and pay-per-query pricing make it an excellent choice for applications that need fast, globally distributed data access without the operational overhead of traditional databases.
Key takeaways:
- D1 excels at read-heavy workloads where data is read close to where it's written, leveraging edge replication for sub-millisecond latency.
- Use prepared statements and batch operations for secure, efficient database access.
- Design for the edge model with denormalized data, covering indexes, and eventually consistent reads.
- Start with D1 for new projects that need a simple, globally distributed database without connection pooling complexity.
Start by creating a D1 database with Wrangler, building a simple CRUD API with Workers, and deploying it to Cloudflare's edge. Refer to the Cloudflare D1 documentation for detailed guides and the Workers documentation for platform integration.