Saltar a contenido

Capítulo 13

Cloudflare D1

Bases de datos SQL distribuidas para aplicaciones modernas

Objetivo

Este capítulo tiene un propósito muy concreto: que el lector comprenda qué es Cloudflare D1, cómo funciona una base de datos relacional, de qué manera se integra con Cloudflare Workers y en qué tipo de proyectos conviene utilizarla. Cloudflare define D1 como una base de datos gestionada y serverless con semántica SQL de SQLite, recuperación ante desastres integrada y acceso desde Workers y por API HTTP.[cite:67][cite:78]

Al finalizar, el lector será capaz de diseñar una base de datos relacional profesional, escribir consultas SQL reales, aplicar buenas prácticas de modelado y conectar esa base de datos con aplicaciones desplegadas en la red global de Cloudflare.[cite:67][cite:64]

¿Qué aprenderás?

  • Qué es Cloudflare D1 y por qué existe.[cite:67]
  • Qué significa que sea una base de datos SQL serverless basada en SQLite.[cite:67]
  • Cómo funciona una base de datos relacional: tablas, filas, columnas, claves, relaciones e índices.[cite:65][cite:71]
  • Cómo crear una base de datos, administrarla y consultarla desde Workers.[cite:78][cite:73]
  • Cómo diseñar un modelo relacional completo para una plataforma educativa.[cite:67][cite:78]
  • Cuándo D1 es una gran opción y cuándo otro motor puede ser más conveniente.[cite:64][cite:67]

Introducción

Imagina una escuela que guarda toda su información en hojas sueltas. En una hoja aparecen los alumnos. En otra, los docentes. En otra, los cursos. En otra más, las credenciales digitales. Si una persona cambia de correo, hay que corregirlo en muchas hojas. Si un alumno se da de baja, todavía puede aparecer inscrito en cursos viejos. Si alguien escribe mal un nombre, el error se propaga a todo el sistema.

Una base de datos relacional nace precisamente para evitar ese caos. Su trabajo no consiste solo en “guardar datos”, sino en organizarlos con reglas claras, relaciones consistentes y consultas capaces de responder preguntas reales: qué alumnos pertenecen a qué escuela, qué docente imparte qué curso, quién generó una credencial y cuándo se registró un acceso.

Cloudflare D1 lleva ese modelo clásico al mundo de la infraestructura moderna. En lugar de obligarte a administrar un servidor de base de datos, instalar un motor, abrir puertos, configurar réplicas y programar respaldos manuales, D1 ofrece una base SQL gestionada que vive dentro del ecosistema de Cloudflare y se integra de forma natural con Workers y Pages.[cite:67]

Si en capítulos anteriores se explicó cómo funcionan el edge, Workers y R2, D1 aparece ahora como la pieza que faltaba para completar muchas arquitecturas reales. Workers procesa lógica, R2 almacena archivos y D1 organiza datos estructurados: usuarios, permisos, cursos, inscripciones, bitácoras y relaciones entre entidades.[cite:67][cite:78]

¿Qué es Cloudflare D1?

Cloudflare D1 es la propuesta de Cloudflare para construir bases de datos SQL serverless sobre su plataforma de desarrollo. En la documentación oficial se presenta como una base de datos gestionada con semántica SQL de SQLite, recuperación integrada y acceso desde Workers y Pages.[cite:67]

Historia

D1 surge como respuesta a una necesidad muy evidente dentro del ecosistema serverless: muchas aplicaciones modernas necesitan una base relacional, pero administrar MySQL o PostgreSQL para proyectos pequeños o medianos añade complejidad operativa innecesaria. D1 intenta reducir esa fricción y acercar SQL al mismo modelo operativo simple que ya popularizaron Workers y otros servicios administrados.[cite:67]

La idea no es reemplazar todos los motores de base de datos del mundo, sino ofrecer una opción integrada, sencilla y cercana al runtime donde vive la aplicación. Por eso D1 se conecta de manera directa con Workers mediante bindings de entorno y una API específica para preparar y ejecutar consultas.[cite:78]

Filosofía

La filosofía de D1 puede resumirse en cuatro ideas:

  • SQL debe seguir siendo accesible para proyectos modernos.[cite:67]
  • La infraestructura debe administrarse lo menos posible.[cite:67]
  • El aislamiento por base de datos puede ser útil para escalar por tenant, usuario o entidad.[cite:67][cite:64]
  • El costo debe depender principalmente de almacenamiento y consultas, no de mantener servidores encendidos.[cite:67][cite:77]

Cloudflare afirma que D1 permite crear miles de bases de datos sin costo extra por aislamiento, algo especialmente útil para arquitecturas multi-tenant o sistemas donde cada cliente, escuela o institución puede tener su propio espacio lógico.[cite:67][cite:64]

SQLite distribuido

D1 utiliza la semántica SQL de SQLite. Eso no significa simplemente “usar un archivo .sqlite” como en una laptop. Significa aprovechar un motor conocido, pequeño y confiable, adaptado a una plataforma gestionada y distribuida donde Cloudflare controla la operación, la recuperación y ciertas capacidades de replicación de lectura.[cite:67]

La documentación oficial destaca dos capacidades especialmente importantes: Time Travel, que permite restaurar la base a un punto en el tiempo, y Global Read Replication, que permite añadir réplicas de solo lectura más cerca de los clientes para reducir latencia en consultas de lectura.[cite:67]

Serverless SQL

Cuando Cloudflare habla de D1 como “serverless”, la idea central es que el desarrollador no administra una máquina ni un proceso de base de datos. No hay que instalar paquetes, definir tamaños de instancia, configurar un sistema operativo o programar backups manuales. La aplicación simplemente se conecta a la base a través de bindings y emite consultas SQL.[cite:67][cite:78]

En el modelo de precios descrito por Cloudflare, D1 cobra por almacenamiento y consultas, y no por capacidad computacional reservada de la base. Si no hay consultas, no se factura cómputo propio de D1, aunque Workers sigue teniendo su propio modelo de costos por ejecución.[cite:77]

Casos de uso

D1 encaja especialmente bien en escenarios como estos:

  • Aplicaciones CRUD con estructura relacional clara.[cite:67]
  • Sistemas multi-tenant con bases por cliente o por institución.[cite:64][cite:67]
  • Plataformas educativas con usuarios, cursos, inscripciones y bitácoras.[cite:67]
  • APIs en Workers que necesitan SQL sin administrar un servidor aparte.[cite:78]
  • Proyectos donde la lectura puede beneficiarse de réplicas cercanas al usuario.[cite:67][cite:78]

¿Qué problema resuelve?

Para entender por qué existe D1, conviene mirar primero el modelo tradicional.

Bases de datos tradicionales

En un enfoque clásico, montar una base de datos relacional implica elegir un motor como MySQL o PostgreSQL, provisionar un servidor, configurar almacenamiento, abrir la red correcta, definir usuarios, monitorear recursos, atender actualizaciones y resolver respaldos. Todo eso funciona, y en muchos escenarios sigue siendo la mejor opción, pero también añade fricción operativa.

Para una startup pequeña, un docente que desarrolla software o un desarrollador independiente, esa carga puede sentirse desproporcionada. El trabajo principal debería centrarse en el modelo de datos y la lógica del sistema, no en mantener viva una instancia de base de datos durante meses.

Administración de servidores

D1 elimina buena parte de esa administración. Cloudflare lo presenta como una base gestionada con acceso directo desde Workers y Pages.[cite:67] En lugar de conectarse a un host TCP convencional, el código de la aplicación usa un binding del entorno y llama métodos como prepare(), batch() o exec().[cite:78]

La diferencia conceptual es importante. En un servidor tradicional, la base es una pieza de infraestructura que tú operas. En D1, la base es un recurso administrado de plataforma.

Escalabilidad

Cloudflare indica que D1 está diseñado para escalar horizontalmente a través de múltiples bases más pequeñas, con un límite por base de 10 GB en planes pagados, y con la posibilidad de tener miles o incluso millones de bases por cuenta bajo ciertos acuerdos de límite ampliado.[cite:64]

Eso cambia la forma de pensar la escala. En vez de imaginar una sola base gigantesca para todos los clientes, puedes diseñar una arquitectura por escuela, por campus, por organización o por tenant. Ese enfoque puede simplificar aislamiento, migraciones, respaldo y control de impacto.[cite:64][cite:67]

Alta disponibilidad y recuperación

D1 incorpora Time Travel para recuperación a un punto en el tiempo dentro de una ventana determinada y ofrece réplicas de lectura para mejorar latencia y throughput en lecturas.[cite:67][cite:64] Esto no elimina toda decisión arquitectónica, pero reduce el trabajo manual necesario para alcanzar un nivel razonable de resiliencia.

Comparación rápida

Aspecto Base tradicional Cloudflare D1
Operación Requiere administrar servidor, red y backups Servicio gestionado por Cloudflare.[cite:67]
Modelo de acceso Conexión a motor externo Binding desde Workers/Pages.[cite:67][cite:78]
Escalado Vertical y/o clúster administrado Escala horizontal con múltiples bases pequeñas.[cite:64]
Recuperación Debe configurarse explícitamente Time Travel integrado.[cite:67]
Lecturas globales Requiere réplica propia Réplicas de lectura disponibles.[cite:67]

Arquitectura de D1

El flujo básico de D1 puede representarse así:

Usuario
   |
   v
Cloudflare Worker
   |
   v
D1
   |
   v
Respuesta

Ese diagrama parece simple, pero dentro de él ocurre algo muy poderoso: la lógica de aplicación y la capa de acceso a datos viven muy cerca una de otra en el ecosistema de Cloudflare. El Worker recibe la solicitud HTTP, valida datos, ejecuta consultas SQL contra D1, transforma resultados en JSON y responde al cliente.[cite:78]

Flujo completo

[ Navegador / App ]
         |
         | HTTP Request
         v
[ Cloudflare Worker ]
         |
         | prepare(), bind(), first(), all(), batch()
         v
[ D1 Database ]
         |
         | resultado SQL
         v
[ Cloudflare Worker ]
         |
         | JSON / HTML / redirect / error
         v
[ Cliente ]

Paso a paso, el flujo es este:

  1. El usuario hace una petición a una URL pública.
  2. Un Worker recibe la petición.
  3. El Worker obtiene acceso a D1 mediante env.DB u otro binding configurado.[cite:78]
  4. El código prepara una consulta SQL.
  5. Si la consulta recibe variables, estas se enlazan con bind().[cite:66][cite:78]
  6. El motor ejecuta la consulta y devuelve filas, metadatos o errores.
  7. El Worker serializa el resultado y lo envía al cliente.

La ventaja de este enfoque es que la API y la base no se sienten como piezas desconectadas. Forman parte de una misma superficie de despliegue.

¿Qué es una base de datos relacional?

Una base de datos relacional organiza la información en tablas. Cada tabla representa una entidad: usuarios, escuelas, alumnos, cursos, inscripciones o credenciales. Cada fila representa un registro concreto y cada columna describe una propiedad de ese registro.

Tablas, filas y columnas

Si se crea una tabla usuarios, probablemente tendrá columnas como id, nombre, email, rol y created_at. Cada fila corresponderá a una persona específica.

Tabla: usuarios
+----+----------------+----------------------+-----------+
| id | nombre         | email                | rol       |
+----+----------------+----------------------+-----------+
| 1  | Ana López      | ana@escuela.edu      | docente   |
| 2  | Luis Martínez  | luis@escuela.edu     | alumno    |
+----+----------------+----------------------+-----------+

Registros

“Registro” es otra forma de llamar a una fila. En muchos contextos, fila y registro se usan como sinónimos.

Relaciones

Una base relacional recibe su nombre porque las tablas pueden relacionarse entre sí. Por ejemplo, una inscripción pertenece a un alumno y a un curso. Eso significa que la tabla inscripciones puede guardar alumno_id y curso_id para conectar ambos mundos.

Llave primaria

La llave primaria identifica de manera única cada registro. En SQLite y D1 suele usarse un INTEGER PRIMARY KEY, aunque también es posible usar IDs de texto según las necesidades del diseño.

Llave foránea

La llave foránea conecta una tabla con otra y ayuda a preservar la integridad de los datos. SQLite documenta el soporte de claves foráneas como un mecanismo para hacer cumplir relaciones entre tablas.[cite:65]

Ejemplo:

CREATE TABLE cursos (
  id INTEGER PRIMARY KEY,
  nombre TEXT NOT NULL
);

CREATE TABLE alumnos (
  id INTEGER PRIMARY KEY,
  nombre TEXT NOT NULL
);

CREATE TABLE inscripciones (
  id INTEGER PRIMARY KEY,
  alumno_id INTEGER NOT NULL,
  curso_id INTEGER NOT NULL,
  FOREIGN KEY (alumno_id) REFERENCES alumnos(id),
  FOREIGN KEY (curso_id) REFERENCES cursos(id)
);

Aquí inscripciones no vive aislada. Depende de que existan un alumno y un curso válidos.

Índices

Un índice acelera búsquedas sobre columnas concretas. Si consultas con frecuencia por email, crear un índice sobre esa columna puede reducir mucho el tiempo de respuesta. SQLite recomienda crear índices apropiados, y Cloudflare insiste en ello para mantener consultas rápidas dentro de los límites de D1.[cite:64][cite:71]

Normalización

Normalizar significa evitar redundancia innecesaria. Si el nombre de una escuela se repite en miles de filas de alumnos, conviene tener una tabla escuelas y referenciarla por ID. Eso reduce inconsistencias y facilita actualización.

No obstante, normalizar no implica fragmentar todo sin criterio. El buen diseño relacional busca equilibrio entre claridad, integridad y rendimiento.

SQLite

Qué es

SQLite es un motor SQL muy conocido por su tamaño reducido, portabilidad y fiabilidad. Cloudflare describe D1 como una base con semántica SQL de SQLite.[cite:67] Esto resulta importante porque permite usar un dialecto ampliamente documentado y muy apto para aplicaciones pequeñas y medianas.

Cómo funciona

En su forma clásica, SQLite trabaja sobre un archivo de base de datos. Pero en D1 ese comportamiento se abstrae detrás de una plataforma gestionada. El desarrollador no manipula directamente el archivo; interactúa con la base a través de la API de D1 y de comandos de Wrangler.[cite:73][cite:78]

Ventajas

  • Simplicidad conceptual.
  • Sintaxis SQL conocida.
  • Excelente para aplicaciones embebidas y escenarios con estructura clara.
  • Buen rendimiento cuando el diseño y los índices son correctos.

Limitaciones

Cloudflare deja claro que cada base D1 es inherentemente single-threaded y procesa consultas una por una.[cite:64] Eso significa que el throughput de una base depende del tiempo promedio de las consultas. Si cada consulta tarda 1 ms, se pueden alcanzar aproximadamente 1,000 consultas por segundo; si tarda 100 ms, el rendimiento baja de forma drástica.[cite:64]

Además, D1 tiene límites por tamaño, duración de consulta, número de parámetros enlazados y número de consultas por invocación del Worker.[cite:64] No es el tipo de base pensado para cualquier carga imaginable; es una herramienta concreta con fortalezas muy útiles y límites muy reales.

Por qué Cloudflare lo eligió

Porque SQLite ofrece un modelo compacto, maduro y bien comprendido, ideal para un servicio SQL serverless que busca ser simple, rápido de adoptar y cercano al runtime de aplicación. La elección también facilita que el desarrollador trabaje con SQL estándar y con un motor sobradamente conocido en la industria.[cite:67][cite:71]

Crear una base de datos

Creación

Cloudflare proporciona comandos específicos de Wrangler para interactuar con D1.[cite:73] El flujo conceptual suele ser este:

  1. Crear la base de datos con Wrangler.
  2. Obtener el identificador de la base.
  3. Declarar el binding en la configuración del proyecto.
  4. Aplicar un esquema inicial o migraciones.
  5. Conectarla al Worker.

Ejemplo representativo del comando de creación:

npx wrangler d1 create plataforma-educativa

Configuración

Una vez creada, se agrega un binding al archivo de configuración del proyecto para que el Worker pueda acceder a la base mediante env.[cite:78][cite:73]

Ejemplo de configuración conceptual:

name = "plataforma-educativa-api"
main = "src/index.ts"
compatibility_date = "2026-06-28"

[[d1_databases]]
binding = "DB"
database_name = "plataforma-educativa"
database_id = "TU_DATABASE_ID"

Conexión

Dentro del Worker, D1 se usa a través del objeto env.

export interface Env {
  DB: D1Database;
}

Administración

D1 puede administrarse con comandos de Wrangler y con su API desde Workers.[cite:73][cite:78] El flujo sano consiste en tratar el esquema como código, versionar cambios y evitar modificar producción manualmente sin historial.

SQL básico

Una gran ventaja de D1 es que permite aprender SQL en un contexto práctico. A continuación aparece una guía compacta con ejemplos funcionales.

CREATE TABLE

CREATE TABLE usuarios (
  id INTEGER PRIMARY KEY,
  nombre TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE,
  rol TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

INSERT

INSERT INTO usuarios (nombre, email, rol)
VALUES ('Ana López', 'ana@escuela.edu', 'docente');

SELECT

SELECT id, nombre, email, rol
FROM usuarios;

UPDATE

UPDATE usuarios
SET rol = 'coordinador'
WHERE id = 1;

DELETE

DELETE FROM usuarios
WHERE id = 1;

ALTER TABLE

SQLite soporta alteraciones de esquema con ciertas limitaciones documentadas en su propia referencia SQL.[cite:68]

ALTER TABLE usuarios ADD COLUMN telefono TEXT;

DROP TABLE

DROP TABLE IF EXISTS usuarios_temp;

WHERE

SELECT *
FROM usuarios
WHERE rol = 'docente';

ORDER BY

SELECT *
FROM usuarios
ORDER BY created_at DESC;

GROUP BY

SELECT rol, COUNT(*) AS total
FROM usuarios
GROUP BY rol;

JOIN

SELECT a.nombre AS alumno, c.nombre AS curso
FROM inscripciones i
JOIN alumnos a ON a.id = i.alumno_id
JOIN cursos c ON c.id = i.curso_id;

LIMIT y OFFSET

SELECT *
FROM alumnos
ORDER BY id DESC
LIMIT 20 OFFSET 40;

COUNT, SUM, AVG, MAX, MIN

SELECT COUNT(*) AS total_usuarios FROM usuarios;
SELECT SUM(calificacion) AS suma FROM evaluaciones;
SELECT AVG(calificacion) AS promedio FROM evaluaciones;
SELECT MAX(calificacion) AS maximo FROM evaluaciones;
SELECT MIN(calificacion) AS minimo FROM evaluaciones;

Relaciones

Las relaciones son el corazón del modelo relacional.

Uno a uno

Se usa cuando un registro tiene exactamente un registro complementario. Ejemplo: un usuario y su perfil extendido.

usuarios 1 ----- 1 perfiles

Uno a muchos

Es la relación más común. Una escuela tiene muchos alumnos. Un docente tiene muchos cursos.

escuelas 1 ----- N alumnos
usuarios 1 ----- N bitacora

Muchos a muchos

Un alumno puede estar inscrito en muchos cursos, y un curso puede tener muchos alumnos. Esa relación se resuelve con una tabla intermedia.

alumnos N ----- N cursos
        \     /
       inscripciones

Integridad referencial

La integridad referencial garantiza que no existan referencias rotas. Si una inscripción apunta a un alumno que no existe, el modelo se vuelve incoherente. Las claves foráneas ayudan precisamente a impedir esos problemas.[cite:65]

Índices

Qué son

Un índice es una estructura auxiliar que acelera búsquedas y ordenamientos sobre columnas específicas. No reemplaza el diseño de tablas, pero puede transformar el rendimiento de una consulta.

Cómo funcionan

Cuando consultas una columna indexada, el motor puede localizar coincidencias sin recorrer toda la tabla. En proyectos reales esto marca una diferencia enorme en endpoints de login, búsqueda por correo, consulta de credenciales, bitácoras y listados filtrados.

Cuándo utilizarlos

  • En columnas usadas frecuentemente en WHERE.
  • En columnas usadas para JOIN.
  • En columnas usadas en ORDER BY.
  • En claves foráneas del lado hijo.

SQLite documenta que conviene crear índices en columnas de claves foráneas hijas; estas no se indexan automáticamente solo por declarar la restricción.[cite:74]

Ejemplo

CREATE INDEX idx_usuarios_email ON usuarios(email);
CREATE INDEX idx_inscripciones_alumno_id ON inscripciones(alumno_id);
CREATE INDEX idx_inscripciones_curso_id ON inscripciones(curso_id);

Optimización

Un índice mal pensado también cuesta: consume espacio y puede ralentizar escrituras. La regla profesional no es “indexar todo”, sino indexar lo que el patrón real de consultas necesita.

Migraciones

Qué son

Una migración es un cambio versionado del esquema de la base: crear tablas, agregar columnas, crear índices o transformar datos. En equipos serios, el esquema nunca debería depender de memoria o cambios manuales improvisados.

Cómo funcionan

El enfoque recomendado consiste en guardar archivos SQL versionados y aplicarlos en orden. Cada migración representa un paso pequeño y reversible cuando sea posible.

Ejemplo:

migrations/
  0001_initial_schema.sql
  0002_add_roles.sql
  0003_add_indexes.sql

Versionado

Versionar migraciones permite responder preguntas muy importantes:

  • ¿Qué cambió entre staging y producción?
  • ¿En qué momento se creó una tabla?
  • ¿Qué despliegue introdujo una columna nueva?

Buenas prácticas

  • Hacer migraciones pequeñas.
  • Probarlas en local antes de producción.
  • No mezclar cambios masivos de datos con cambios estructurales si puede evitarse.
  • Ejecutar operaciones grandes en lotes, porque D1 tiene límites de duración y Cloudflare recomienda batchear trabajos pesados.[cite:64]

Integración con Workers

Aquí D1 se vuelve especialmente interesante, porque no es solo una base SQL: es una base SQL integrada con el runtime de Cloudflare.

Consultas

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const users = await env.DB.prepare(
      'SELECT id, nombre, email, rol FROM usuarios ORDER BY id DESC LIMIT 20'
    ).all();

    return Response.json(users.results);
  }
};

La documentación oficial de D1 explica que prepare() devuelve un objeto de prepared statement y que luego pueden usarse métodos específicos para ejecutar la consulta y obtener resultados.[cite:78][cite:66]

Inserciones

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const body = await request.json() as { nombre: string; email: string; rol: string };

    const result = await env.DB
      .prepare('INSERT INTO usuarios (nombre, email, rol) VALUES (?, ?, ?)')
      .bind(body.nombre, body.email, body.rol)
      .run();

    return Response.json({ ok: true, meta: result.meta }, { status: 201 });
  }
};

Actualizaciones

await env.DB
  .prepare('UPDATE usuarios SET rol = ? WHERE id = ?')
  .bind('coordinador', 10)
  .run();

Prepared Statements

Cloudflare recomienda el uso de prepared statements y el método bind() para enlazar parámetros de manera dinámica.[cite:66][cite:78] Esto no solo mejora claridad y reutilización; también es una defensa fundamental contra SQL Injection.

Batch y transacciones

La API batch() permite enviar múltiples sentencias en una sola llamada. Cloudflare documenta que estas sentencias se ejecutan de forma secuencial, no concurrente, y que un fallo puede abortar o revertir la secuencia completa, tratándose como una transacción.[cite:78]

Ejemplo:

const stmt1 = env.DB.prepare(
  'INSERT INTO cursos (nombre) VALUES (?)'
).bind('Arquitectura Web Moderna');

const stmt2 = env.DB.prepare(
  'INSERT INTO bitacora (evento, detalle) VALUES (?, ?)'
).bind('curso_creado', 'Se creó un curso nuevo');

await env.DB.batch([stmt1, stmt2]);

Manejo de errores

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    try {
      const rows = await env.DB.prepare('SELECT * FROM usuarios').all();
      return Response.json({ ok: true, data: rows.results });
    } catch (error) {
      console.error('D1 error', error);
      return Response.json(
        { ok: false, error: 'Database operation failed' },
        { status: 500 }
      );
    }
  }
};

Seguridad

Validación

La seguridad comienza antes del SQL. Todo dato proveniente del usuario debe validarse: tipo, longitud, formato, obligatoriedad, rango y permisos.

SQL Injection

El riesgo clásico de inyección SQL aparece cuando se concatenan cadenas manualmente.

Malo:

const email = new URL(request.url).searchParams.get('email');
const sql = `SELECT * FROM usuarios WHERE email = '${email}'`;
await env.DB.exec(sql);

Mejor:

const email = new URL(request.url).searchParams.get('email');
const row = await env.DB
  .prepare('SELECT * FROM usuarios WHERE email = ?')
  .bind(email)
  .first();

Cloudflare señala que exec() es menos seguro y menos adecuado para uso frecuente; se reserva mejor para mantenimiento o tareas puntuales, mientras que las consultas habituales deben usar statements preparados.[cite:78]

Buenas prácticas

  • Usar prepare() y bind() para datos dinámicos.[cite:66][cite:78]
  • Validar entrada antes de consultar.
  • No exponer detalles internos de errores SQL al cliente.
  • Limitar permisos a nivel de aplicación.
  • Registrar eventos sensibles en bitácora.

Rendimiento

Consultas eficientes

Cada D1 individual procesa consultas una por una.[cite:64] Eso vuelve crucial escribir SQL eficiente. Una consulta lenta no solo tarda más; también bloquea la capacidad de atender otras operaciones en esa misma base.

Índices y patrones de acceso

Cloudflare recomienda usar índices apropiados para mantener consultas rápidas y eficientes.[cite:64] En la práctica, cualquier columna usada en joins, filtros por identidad o búsquedas frecuentes debe revisarse con atención.

Límites importantes

Entre los límites documentados por Cloudflare se incluyen:[cite:64]

  • Hasta 10 GB por base en plan pagado y 500 MB en free.
  • Hasta 50,000 bases por cuenta en Workers Paid y 10 en Free.
  • Hasta 1,000 consultas por invocación del Worker en Paid y 50 en Free.
  • Duración máxima de consulta SQL de 30 segundos.
  • Hasta 100 parámetros enlazados por consulta.
  • Aproximadamente 6 conexiones simultáneas a D1 por invocación.

Escalabilidad

D1 está pensado para escalar horizontalmente con muchas bases pequeñas.[cite:64] Si un sistema crece por institución, por sede o por cliente, puede ser sensato repartir la carga en múltiples bases y no esperar que una sola absorba todo para siempre.

Comparativa

La pregunta correcta no es “qué base es mejor”, sino “qué base encaja mejor con este problema”.

Opción Fortalezas Cuándo conviene
Cloudflare D1 SQL gestionado, integración nativa con Workers/Pages, Time Travel, replicas de lectura.[cite:67][cite:78] APIs serverless, multi-tenant, apps en ecosistema Cloudflare.
SQLite Muy simple, ligero, excelente para entornos locales o embebidos.[cite:71] Apps locales, prototipos, software embebido.
MySQL Popular, maduro, ampliamente soportado. Sistemas web tradicionales con operación propia.
PostgreSQL Muy potente, extensible y sólido para cargas complejas. Plataformas con consultas avanzadas y operación más sofisticada.
MariaDB Familiar para equipos que vienen de MySQL. Escenarios similares a MySQL.
SQL Server Ecosistema fuerte en organizaciones Microsoft. Entornos empresariales con stack Microsoft.
Firestore NoSQL documental, flexible para ciertos patrones. Apps con documentos y sincronización, no necesariamente relaciones complejas.
Supabase PostgreSQL gestionado con ecosistema moderno. Productos que quieren SQL clásico con capa SaaS.
PlanetScale MySQL serverless/distribuido para alto crecimiento. SaaS con fuerte necesidad de escalado sobre MySQL.

Una comparación objetiva obliga a admitir que D1 no sustituye todos estos motores. Su gran ventaja aparece cuando la aplicación ya vive en Cloudflare o cuando el modelo operativo serverless tiene más valor que la flexibilidad extrema de un motor tradicional.

Casos reales

Credenciales Digitales

Un sistema de credenciales necesita usuarios, instituciones, plantillas, credenciales emitidas, vigencias, revocaciones y logs de consulta. D1 es adecuado porque todo ese dominio es relacional por naturaleza: una credencial pertenece a un usuario, una institución emite muchas credenciales, un acceso puede quedar registrado en una bitácora y un rol controla permisos de administración.[cite:67][cite:78]

Sistema de inscripciones

Inscribir alumnos en cursos es un caso clásico de relación muchos a muchos. D1 permite modelarlo con claridad mediante tablas intermedias y consultas SQL muy expresivas.

Sistema escolar

Escuelas, grupos, materias, docentes, horarios, evaluaciones y asistencias suelen tener relaciones claras y consultas estructuradas. SQL resulta natural aquí porque el dominio exige consistencia y reportes.

Incluso un acortador de enlaces puede beneficiarse de D1 cuando necesita guardar códigos cortos, URL destino, propietario, expiración, métricas resumidas y reglas de acceso. El esquema es pequeño, pero relacional.

Usuarios, roles y permisos

La autorización casi siempre termina siendo relacional. Un usuario puede tener uno o varios roles, y cada rol puede tener varios permisos. D1 encaja muy bien en este tipo de diseño.

Bitácoras y accesos

Registrar eventos de acceso, auditoría y operaciones administrativas es un patrón excelente para SQL: quién hizo qué, cuándo, desde dónde y sobre qué entidad.

Diagramas ASCII

Arquitectura general

+---------+       +-------------------+       +----------------+
| Usuario | ----> | Cloudflare Worker | ----> | Cloudflare D1  |
+---------+       +-------------------+       +----------------+
      ^                      |                          |
      |                      +------ JSON / HTML -------+
      +-------------------------------------------------+

Worker hacia D1

Request
  |
  v
Worker
  |
  | prepare('SELECT * FROM usuarios WHERE id = ?')
  | bind(25)
  v
D1
  |
  | first()
  v
Worker
  |
  v
Response

Modelo relacional sencillo

escuelas (1) --------- (N) alumnos
escuelas (1) --------- (N) docentes
cursos   (1) --------- (N) inscripciones
alumnos  (1) --------- (N) inscripciones
usuarios (1) --------- (N) bitacora
roles    (N) --------- (N) usuarios_roles --------- (N) usuarios

Consulta con join

[alumnos] -- alumno_id --> [inscripciones] <-- curso_id -- [cursos]
      |                                                    |
      +-------------------- consulta JOIN -----------------+

Flujo completo

Cliente
  |
  | GET /api/alumnos/42/cursos
  v
Worker
  |
  | valida parámetros
  | prepara SQL
  | ejecuta JOIN en D1
  v
D1
  |
  | devuelve filas
  v
Worker
  |
  | serializa JSON
  v
Cliente

Buenas prácticas

  • Diseñar primero el modelo de datos y después los endpoints.
  • Usar nombres de tablas y columnas consistentes.
  • Declarar claves primarias y foráneas desde el inicio.[cite:65]
  • Crear índices para consultas reales, no por intuición.[cite:64][cite:74]
  • Usar statements preparados para toda entrada dinámica.[cite:66][cite:78]
  • Mantener migraciones pequeñas y versionadas.
  • Procesar cambios masivos en lotes para no rebasar límites.[cite:64]
  • Separar datos estructurados en D1 y archivos binarios en R2 cuando corresponda.

Errores comunes

  • Pensar que una base relacional es solo “un Excel más elegante”.
  • Repetir datos por comodidad en lugar de modelar relaciones.
  • No crear índices en columnas críticas.
  • Concatenar SQL con strings.
  • Hacer una sola consulta masiva que intenta modificar millones de filas de una vez, algo que Cloudflare desaconseja por límites de ejecución.[cite:64]
  • Esperar que una única base D1 resuelva por sí sola cualquier escala imaginable.
  • Confundir D1 con Firestore o con un almacén de documentos.

Curiosidades

  • Cloudflare indica que D1 permite crear miles de bases de datos sin costo extra por aislamiento, lo que invita a pensar arquitecturas por cliente, por escuela o por tenant de manera más natural que en infraestructuras tradicionales.[cite:67]
  • D1 incorpora Time Travel para restauración a cualquier minuto dentro de la ventana soportada por el plan.[cite:67][cite:64]
  • Para aprovechar réplicas de lectura hace falta usar la Sessions API; de otro modo, las consultas siguen ejecutándose sobre la primaria.[cite:78]

Resumen

Cloudflare D1 es una base de datos SQL gestionada que toma la semántica de SQLite y la integra con el ecosistema serverless de Cloudflare.[cite:67] Su propuesta principal no consiste en competir con todos los motores relacionales en todos los escenarios, sino en simplificar el desarrollo de aplicaciones modernas que necesitan SQL sin administrar servidores.[cite:67][cite:77]

Su verdadero valor aparece cuando se combina con Workers. El Worker recibe una petición, valida, consulta la base con prepared statements, transforma el resultado y responde.[cite:78][cite:66] Si el esquema está bien diseñado, las relaciones son claras y los índices correctos, D1 puede convertirse en una pieza muy poderosa para sistemas educativos, plataformas multi-tenant, APIs y servicios web modernos.[cite:64][cite:67]

Glosario

  • Base de datos relacional: sistema que organiza información en tablas relacionadas entre sí.
  • Tabla: conjunto estructurado de filas y columnas.
  • Fila / registro: una entrada individual dentro de una tabla.
  • Columna: atributo de una tabla.
  • Llave primaria: identificador único de cada registro.
  • Llave foránea: referencia a la llave primaria de otra tabla.[cite:65]
  • Índice: estructura que acelera consultas sobre columnas concretas.
  • Normalización: técnica para reducir redundancia y mejorar consistencia.
  • SQLite: motor SQL ligero cuyas semánticas usa D1.[cite:67]
  • D1: base de datos SQL serverless gestionada por Cloudflare.[cite:67]
  • Prepared Statement: consulta preparada con parámetros enlazables.[cite:66][cite:78]
  • bind(): método para asociar valores a una consulta preparada.[cite:66]
  • batch(): método para ejecutar varias sentencias secuenciales como transacción.[cite:78]
  • Time Travel: capacidad de restaurar a un punto en el tiempo.[cite:67]
  • Read Replica: copia de solo lectura para reducir latencia y aumentar throughput en lectura.[cite:67]
  • Worker binding: recurso enlazado al entorno del Worker, por ejemplo una base D1.[cite:78]
  • JOIN: operación SQL para combinar datos de varias tablas.
  • Migración: cambio versionado del esquema de la base.
  • Integridad referencial: consistencia entre relaciones de tablas.[cite:65]
  • SQL Injection: ataque que intenta alterar consultas mediante entrada manipulada.

20 preguntas de autoevaluación

  1. ¿Qué es Cloudflare D1 y cómo lo define Cloudflare oficialmente?[cite:67]
  2. ¿Qué significa que D1 use semántica SQL de SQLite?[cite:67]
  3. ¿Qué diferencia a D1 de una base relacional tradicional autogestionada?
  4. ¿Qué ventajas ofrece el modelo serverless aplicado a SQL?[cite:67][cite:77]
  5. ¿Qué papel cumplen Workers en la arquitectura de D1?[cite:78]
  6. ¿Qué es una tabla en una base de datos relacional?
  7. ¿Qué diferencia hay entre llave primaria y llave foránea?[cite:65]
  8. ¿Cómo se modela una relación muchos a muchos?
  9. ¿Por qué conviene usar índices en columnas usadas para WHERE o JOIN?[cite:64][cite:74]
  10. ¿Qué problema resuelven las migraciones?
  11. ¿Qué ventajas tiene prepare() frente a exec()?[cite:78]
  12. ¿Por qué bind() ayuda a prevenir SQL Injection?[cite:66]
  13. ¿Qué hace batch() en la API de D1?[cite:78]
  14. ¿Qué límites importantes impone D1 por base o por invocación?[cite:64]
  15. ¿Qué significa que cada base D1 sea single-threaded?[cite:64]
  16. ¿Qué es Time Travel y para qué sirve?[cite:67][cite:64]
  17. ¿Cómo se relacionan D1 y las réplicas de lectura globales?[cite:67][cite:78]
  18. ¿En qué tipo de sistema educativo D1 puede ser una opción adecuada?
  19. ¿Cuándo sería mejor usar PostgreSQL o MySQL en lugar de D1?
  20. ¿Cómo diseñarías una arquitectura multi-tenant usando varias bases D1?[cite:64][cite:67]

Proyecto práctico

A continuación se diseña una base de datos para una plataforma educativa completa. La meta no es solo “tener tablas”, sino construir un modelo coherente para operar escuelas, usuarios, docentes, alumnos, cursos, credenciales y auditoría.

Objetivo del proyecto

Construir una base de datos relacional profesional y conectarla con un Worker para exponer operaciones comunes mediante consultas parametrizadas.

Modelo general

escuelas
  |
  +-- docentes
  |
  +-- alumnos
  |
  +-- cursos --< inscripciones >-- alumnos
  |
  +-- credenciales

usuarios --< usuarios_roles >-- roles --< roles_permisos >-- permisos
usuarios ------------------------------------------------------< bitacora

Esquema SQL

CREATE TABLE escuelas (
  id INTEGER PRIMARY KEY,
  nombre TEXT NOT NULL,
  clave TEXT NOT NULL UNIQUE,
  municipio TEXT,
  estado TEXT,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE usuarios (
  id INTEGER PRIMARY KEY,
  escuela_id INTEGER,
  nombre TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  activo INTEGER NOT NULL DEFAULT 1,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (escuela_id) REFERENCES escuelas(id)
);

CREATE TABLE roles (
  id INTEGER PRIMARY KEY,
  nombre TEXT NOT NULL UNIQUE,
  descripcion TEXT
);

CREATE TABLE permisos (
  id INTEGER PRIMARY KEY,
  codigo TEXT NOT NULL UNIQUE,
  descripcion TEXT
);

CREATE TABLE usuarios_roles (
  usuario_id INTEGER NOT NULL,
  rol_id INTEGER NOT NULL,
  PRIMARY KEY (usuario_id, rol_id),
  FOREIGN KEY (usuario_id) REFERENCES usuarios(id),
  FOREIGN KEY (rol_id) REFERENCES roles(id)
);

CREATE TABLE roles_permisos (
  rol_id INTEGER NOT NULL,
  permiso_id INTEGER NOT NULL,
  PRIMARY KEY (rol_id, permiso_id),
  FOREIGN KEY (rol_id) REFERENCES roles(id),
  FOREIGN KEY (permiso_id) REFERENCES permisos(id)
);

CREATE TABLE docentes (
  id INTEGER PRIMARY KEY,
  escuela_id INTEGER NOT NULL,
  usuario_id INTEGER,
  nombre TEXT NOT NULL,
  numero_empleado TEXT,
  especialidad TEXT,
  FOREIGN KEY (escuela_id) REFERENCES escuelas(id),
  FOREIGN KEY (usuario_id) REFERENCES usuarios(id)
);

CREATE TABLE alumnos (
  id INTEGER PRIMARY KEY,
  escuela_id INTEGER NOT NULL,
  usuario_id INTEGER,
  matricula TEXT NOT NULL UNIQUE,
  nombre TEXT NOT NULL,
  curp TEXT,
  grado TEXT,
  grupo TEXT,
  FOREIGN KEY (escuela_id) REFERENCES escuelas(id),
  FOREIGN KEY (usuario_id) REFERENCES usuarios(id)
);

CREATE TABLE cursos (
  id INTEGER PRIMARY KEY,
  escuela_id INTEGER NOT NULL,
  docente_id INTEGER,
  nombre TEXT NOT NULL,
  descripcion TEXT,
  activo INTEGER NOT NULL DEFAULT 1,
  FOREIGN KEY (escuela_id) REFERENCES escuelas(id),
  FOREIGN KEY (docente_id) REFERENCES docentes(id)
);

CREATE TABLE inscripciones (
  id INTEGER PRIMARY KEY,
  alumno_id INTEGER NOT NULL,
  curso_id INTEGER NOT NULL,
  fecha_inscripcion TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  estado TEXT NOT NULL DEFAULT 'activa',
  FOREIGN KEY (alumno_id) REFERENCES alumnos(id),
  FOREIGN KEY (curso_id) REFERENCES cursos(id)
);

CREATE TABLE credenciales (
  id INTEGER PRIMARY KEY,
  alumno_id INTEGER NOT NULL,
  folio TEXT NOT NULL UNIQUE,
  url_qr TEXT,
  estatus TEXT NOT NULL DEFAULT 'vigente',
  emitida_en TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  vence_en TEXT,
  FOREIGN KEY (alumno_id) REFERENCES alumnos(id)
);

CREATE TABLE bitacora (
  id INTEGER PRIMARY KEY,
  usuario_id INTEGER,
  evento TEXT NOT NULL,
  entidad TEXT,
  entidad_id INTEGER,
  detalle TEXT,
  ip TEXT,
  creado_en TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (usuario_id) REFERENCES usuarios(id)
);

Índices del proyecto

CREATE INDEX idx_usuarios_escuela_id ON usuarios(escuela_id);
CREATE INDEX idx_docentes_escuela_id ON docentes(escuela_id);
CREATE INDEX idx_alumnos_escuela_id ON alumnos(escuela_id);
CREATE INDEX idx_cursos_escuela_id ON cursos(escuela_id);
CREATE INDEX idx_cursos_docente_id ON cursos(docente_id);
CREATE INDEX idx_inscripciones_alumno_id ON inscripciones(alumno_id);
CREATE INDEX idx_inscripciones_curso_id ON inscripciones(curso_id);
CREATE INDEX idx_credenciales_alumno_id ON credenciales(alumno_id);
CREATE INDEX idx_bitacora_usuario_id ON bitacora(usuario_id);
CREATE INDEX idx_bitacora_creado_en ON bitacora(creado_en);

Explicación de tablas

  • escuelas: representa cada institución.
  • usuarios: concentra cuentas de acceso al sistema.
  • roles y permisos: definen autorización.
  • usuarios_roles y roles_permisos: resuelven relaciones muchos a muchos.
  • docentes y alumnos: contienen perfiles académicos ligados a escuelas.
  • cursos: oferta académica.
  • inscripciones: relación entre alumnos y cursos.
  • credenciales: emisión y control de credenciales digitales.
  • bitacora: auditoría y trazabilidad del sistema.

Consultas reales

1. Crear un alumno

INSERT INTO alumnos (escuela_id, matricula, nombre, curp, grado, grupo)
VALUES (1, 'A20260001', 'María Hernández', 'HEMM010101MJCXXX01', '3', 'A');

2. Listar cursos de un alumno

SELECT c.id, c.nombre, c.descripcion, i.fecha_inscripcion, i.estado
FROM inscripciones i
JOIN cursos c ON c.id = i.curso_id
WHERE i.alumno_id = ?
ORDER BY i.fecha_inscripcion DESC;

3. Contar alumnos por escuela

SELECT e.nombre, COUNT(a.id) AS total_alumnos
FROM escuelas e
LEFT JOIN alumnos a ON a.escuela_id = e.id
GROUP BY e.id, e.nombre
ORDER BY total_alumnos DESC;

4. Obtener credencial vigente por alumno

SELECT c.folio, c.url_qr, c.emitida_en, c.vence_en
FROM credenciales c
WHERE c.alumno_id = ?
  AND c.estatus = 'vigente'
LIMIT 1;

5. Registrar evento en bitácora

INSERT INTO bitacora (usuario_id, evento, entidad, entidad_id, detalle, ip)
VALUES (?, 'credencial_consultada', 'credenciales', ?, ?, ?);

6. Listar docentes y cursos asignados

SELECT d.nombre AS docente, c.nombre AS curso
FROM docentes d
LEFT JOIN cursos c ON c.docente_id = d.id
WHERE d.escuela_id = ?
ORDER BY d.nombre, c.nombre;

Integración con Workers

export interface Env {
  DB: D1Database;
}

function json(data: unknown, status = 200): Response {
  return new Response(JSON.stringify(data), {
    status,
    headers: { 'content-type': 'application/json; charset=utf-8' }
  });
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    try {
      if (request.method === 'GET' && url.pathname.startsWith('/api/alumnos/')) {
        const alumnoId = Number(url.pathname.split('/')[3]);
        if (!Number.isInteger(alumnoId) || alumnoId <= 0) {
          return json({ ok: false, error: 'ID inválido' }, 400);
        }

        const alumno = await env.DB
          .prepare('SELECT id, nombre, matricula, grado, grupo FROM alumnos WHERE id = ?')
          .bind(alumnoId)
          .first();

        if (!alumno) {
          return json({ ok: false, error: 'Alumno no encontrado' }, 404);
        }

        const cursos = await env.DB
          .prepare(`
            SELECT c.id, c.nombre, i.fecha_inscripcion, i.estado
            FROM inscripciones i
            JOIN cursos c ON c.id = i.curso_id
            WHERE i.alumno_id = ?
            ORDER BY i.fecha_inscripcion DESC
          `)
          .bind(alumnoId)
          .all();

        return json({ ok: true, alumno, cursos: cursos.results });
      }

      if (request.method === 'POST' && url.pathname === '/api/alumnos') {
        const body = await request.json() as {
          escuela_id: number;
          matricula: string;
          nombre: string;
          curp?: string;
          grado?: string;
          grupo?: string;
        };

        if (!body.escuela_id || !body.matricula || !body.nombre) {
          return json({ ok: false, error: 'Faltan campos obligatorios' }, 400);
        }

        const result = await env.DB
          .prepare(`
            INSERT INTO alumnos (escuela_id, matricula, nombre, curp, grado, grupo)
            VALUES (?, ?, ?, ?, ?, ?)
          `)
          .bind(
            body.escuela_id,
            body.matricula,
            body.nombre,
            body.curp ?? null,
            body.grado ?? null,
            body.grupo ?? null
          )
          .run();

        return json({ ok: true, meta: result.meta }, 201);
      }

      return json({ ok: false, error: 'Ruta no encontrada' }, 404);
    } catch (error) {
      console.error(error);
      return json({ ok: false, error: 'Error interno de base de datos' }, 500);
    }
  }
};

Cómo escalar con miles de usuarios

Aquí conviene pensar con mentalidad de arquitectura, no solo de tablas. Cloudflare explica que D1 está diseñado para escalar horizontalmente con múltiples bases pequeñas.[cite:64] Por tanto, una plataforma educativa con muchas instituciones puede crecer de varias maneras:

  • Base única inicial para etapa temprana, cuando el volumen todavía es moderado.
  • Separación por institución cuando cada escuela justifica aislamiento de datos y carga.
  • Réplicas de lectura para escenarios con muchas consultas de lectura distribuidas globalmente.[cite:67][cite:78]
  • Indices y consultas optimizadas para mantener throughput alto en cada base.[cite:64]
  • R2 para archivos grandes y D1 solo para metadatos, evitando mezclar binarios en SQL.

Evolución arquitectónica sugerida

Fase 1:
Worker único -> D1 único -> R2 opcional

Fase 2:
Worker único -> D1 por escuela -> R2 compartido -> logs centralizados

Fase 3:
Workers por dominio funcional -> D1 por tenant -> replicas de lectura -> colas/eventos para procesos asíncronos

La gran lección de este capítulo es que una base relacional bien diseñada no empieza por la tecnología, sino por el modelo de información. D1 facilita la operación, pero sigue exigiendo criterio técnico: relaciones correctas, SQL limpio, índices útiles, validación estricta y una arquitectura pensada para el crecimiento.[cite:64][cite:67][cite:78]