This project uses Cloudflare D1, a serverless SQLite database that runs at the edge. This guide explains how we use it and how to work with it.
D1 is Cloudflare's serverless SQL database built on SQLite. It provides:
- SQLite compatibility - Standard SQL syntax you already know
- Edge deployment - Database runs close to your users
- Zero configuration - No servers to manage
- Local development - Test with a real SQLite database locally
- Migrations - Version-controlled schema changes
We use a local SQLite database for development that mirrors production.
# Initialize local database with schema
wrangler d1 migrations apply blt-api --local
# Load sample data for testing
wrangler d1 execute blt-api --local --file=test_data.sqlThe local database is stored in .wrangler/state/v3/d1/ as a standard SQLite file.
Production uses Cloudflare's managed D1 service.
# Apply schema changes to production
wrangler d1 migrations apply blt-api --remoteConfiguration is in wrangler.toml:
[[d1_databases]]
binding = "blt_api" # Variable name used in code
database_name = "blt-api" # Display name
database_id = "abc-123-xyz" # Unique identifierIn your handler functions:
from libs.db import get_db_safe
async def handle_domains(request, env, ...):
# Get database connection
db = await get_db_safe(env)
# Execute query
result = await db.prepare(
"SELECT * FROM domains WHERE id = ?"
).bind(domain_id).first()
# Convert result to Python
data = result.to_py() if hasattr(result, 'to_py') else resultSelect multiple rows:
result = await db.prepare(
"SELECT * FROM domains LIMIT ? OFFSET ?"
).bind(limit, offset).all()
# Convert results to Python list
from handlers.domains import convert_d1_results
data = convert_d1_results(result.results)Select single row:
result = await db.prepare(
"SELECT * FROM domains WHERE id = ?"
).bind(domain_id).first()
# Returns None if not found, or a row object
if result:
data = result.to_py() if hasattr(result, 'to_py') else resultInsert data:
result = await db.prepare(
"INSERT INTO domains (name, url) VALUES (?, ?)"
).bind(name, url).run()Count rows:
result = await db.prepare(
"SELECT COUNT(*) as total FROM domains"
).first()
total = result['total'] if result else 0For debugging or manual data management:
# Query local database
wrangler d1 execute blt-api --local --command "SELECT * FROM domains;"
# Query production database
wrangler d1 execute blt-api --remote --command "SELECT * FROM domains;"
# Execute SQL from file
wrangler d1 execute blt-api --local --file=queries.sqlMigrations are numbered SQL files in the migrations/ folder:
migrations/
0001_init.sql # Initial schema
0002_add_users.sql # Add users table
0003_modify_domains.sql # Modify domains table
Each migration runs once and is tracked in the d1_migrations table.
wrangler d1 migrations create blt-api <description>Example:
wrangler d1 migrations create blt-api add-description-columnThis creates: migrations/0002_add-description-column.sql
Edit the generated file:
-- Migration number: 0002 2026-02-17T10:00:00.000Z
ALTER TABLE domains ADD COLUMN description TEXT;Important patterns:
-- Create table (use IF NOT EXISTS)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL
);
-- Add index
CREATE INDEX idx_users_username ON users(username);
-- Add auto-update trigger
CREATE TRIGGER update_users_modified
AFTER UPDATE ON users
BEGIN
UPDATE users SET modified = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;Step 1: Test locally
wrangler d1 migrations apply blt-api --localStep 2: Verify it works
wrangler dev --port 8787
# Test your endpointsStep 3: Apply to production
wrangler d1 migrations apply blt-api --remoteStep 4: Deploy code
wrangler deploy# See which migrations are applied
wrangler d1 migrations list blt-api --local
wrangler d1 migrations list blt-api --remoteWe provide helper functions in src/libs/db.py:
Gets the database connection from environment.
from libs.db import get_db
db = get_db(env)Gets database and verifies required tables exist. Use this in handlers.
from libs.db import get_db_safe
try:
db = await get_db_safe(env)
except Exception as e:
return error_response(str(e), status=503)Converts D1 JavaScript proxy objects to Python lists.
from handlers.domains import convert_d1_results
result = await db.prepare("SELECT * FROM domains").all()
data = convert_d1_results(result.results) # List of dictsD1 returns JavaScript proxy objects that need conversion to Python:
For lists (all()):
result = await db.prepare("SELECT * FROM domains").all()
data = convert_d1_results(result.results)
# Returns: [{'id': 1, 'name': 'example'}, ...]For single row (first()):
result = await db.prepare("SELECT * FROM domains WHERE id = 1").first()
if result:
if hasattr(result, 'to_py'):
data = result.to_py()
else:
data = result
# Returns: {'id': 1, 'name': 'example'}For counts/aggregates:
result = await db.prepare("SELECT COUNT(*) as total FROM domains").first()
if hasattr(result, 'to_py'):
result = result.to_py()
total = result.get('total', 0) if isinstance(result, dict) else 0Main table for domain information.
CREATE TABLE domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- Domain name (max 255 chars)
url TEXT NOT NULL, -- Domain URL (max 200 chars)
logo TEXT, -- Logo URL
webshot TEXT, -- Screenshot URL
clicks INTEGER, -- Click count
email_event TEXT, -- Email event type
color TEXT, -- Display color
github TEXT, -- GitHub username
email TEXT, -- Contact email
twitter TEXT, -- Twitter handle
facebook TEXT, -- Facebook URL
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
modified TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
has_security_txt BOOLEAN DEFAULT 0,
security_txt_checked_at TIMESTAMP,
organization INTEGER -- Organization ID (no FK yet)
);Indexes:
idx_domains_organization- Filter by organizationidx_domains_is_active- Filter active domainsidx_domains_created- Sort by creation date
Reusable tags for categorization.
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);Links domains to tags (many-to-many).
CREATE TABLE domain_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
UNIQUE(domain_id, tag_id)
);Indexes:
idx_domain_tags_domain- Get tags for a domainidx_domain_tags_tag- Get domains with a tag
# Get page and per_page from query params
page = int(request.query.get('page', 1))
per_page = min(int(request.query.get('per_page', 20)), 100)
# Calculate offset
offset = (page - 1) * per_page
# Get total count
count_result = await db.prepare("SELECT COUNT(*) as total FROM domains").first()
total = count_result['total'] if count_result else 0
# Get paginated data
result = await db.prepare(
"SELECT * FROM domains LIMIT ? OFFSET ?"
).bind(per_page, offset).all()
data = convert_d1_results(result.results)
# Return with pagination metadata
return json_response({
"success": True,
"data": data,
"pagination": {
"page": page,
"per_page": per_page,
"count": len(data),
"total": total,
"total_pages": (total + per_page - 1) // per_page
}
})# Build query with conditions
conditions = []
params = []
if domain_filter:
conditions.append("domain_id = ?")
params.append(domain_filter)
if is_active is not None:
conditions.append("is_active = ?")
params.append(1 if is_active else 0)
where_clause = " AND ".join(conditions) if conditions else "1=1"
# Execute query
result = await db.prepare(
f"SELECT * FROM domains WHERE {where_clause}"
).bind(*params).all()# Get domains with their tags
result = await db.prepare("""
SELECT
d.id,
d.name,
d.url,
GROUP_CONCAT(t.name) as tags
FROM domains d
LEFT JOIN domain_tags dt ON d.id = dt.domain_id
LEFT JOIN tags t ON dt.tag_id = t.id
GROUP BY d.id
""").all()
data = convert_d1_results(result.results)# List all tables
wrangler d1 execute blt-api --local --command \
"SELECT name FROM sqlite_master WHERE type='table';"
# View table structure
wrangler d1 execute blt-api --local --command \
"PRAGMA table_info(domains);"
# View sample data
wrangler d1 execute blt-api --local --command \
"SELECT * FROM domains LIMIT 5;"# Delete local database
rm -rf .wrangler/state/v3/d1/
# Reapply migrations
wrangler d1 migrations apply blt-api --local
# Reload test data
wrangler d1 execute blt-api --local --file=test_data.sql# Use EXPLAIN to see query plan
wrangler d1 execute blt-api --local --command \
"EXPLAIN QUERY PLAN SELECT * FROM domains WHERE is_active = 1;"Good:
result = await db.prepare(
"SELECT * FROM domains WHERE id = ?"
).bind(domain_id).all()Bad (SQL injection risk):
result = await db.prepare(
f"SELECT * FROM domains WHERE id = {domain_id}"
).all()# Always convert D1 results to Python types
result = await db.prepare("SELECT * FROM domains").all()
data = convert_d1_results(result.results) # Do thistry:
db = await get_db_safe(env)
result = await db.prepare("SELECT * FROM domains").all()
data = convert_d1_results(result.results)
return json_response({"success": True, "data": data})
except Exception as e:
return error_response(f"Database error: {str(e)}", status=500)Always test database changes locally before applying to production:
- Apply migration locally
- Test with
wrangler dev - Verify data with command line queries
- Then apply to remote
- Database size: 100MB per database (free plan)
- Query execution: 30 seconds max
- Rows per query: No hard limit, but be reasonable
- Concurrent operations: D1 handles this automatically
- TEXT max length: No limit in SQLite
- INTEGER range: -9223372036854775808 to 9223372036854775807
- BLOB max size: 1GB (theoretical), 10MB (practical)
We add application-level constraints using CHECK:
CREATE TABLE domains (
name TEXT CHECK(LENGTH(name) >= 1 AND LENGTH(name) <= 255),
url TEXT CHECK(LENGTH(url) >= 1 AND LENGTH(url) <= 200)
);All information in this guide is based on Cloudflare's official documentation:
- Cloudflare D1 Documentation
- D1 Client API Reference
- Wrangler D1 Commands
- SQLite SQL Reference
- Python Workers with D1
For specific implementation details not covered here, refer to the official Cloudflare D1 documentation.