SQLite en Node.js: en memoria y en disco
Durante todo el curso has escrito SQL dentro del playground del navegador. Pero el objetivo final es usar SQL en tus propias aplicaciones. En esta lección vamos a dar el salto: ejecutar SQL de verdad desde Node.js, sin instalar ninguna dependencia, usando el módulo node:sqlite que viene incluido en Node.
Versión soportada: el módulo
node:sqliterequiere Node.js 22.5 o superior. En este curso usamos Node.js 24 LTS, donde la API es estable y no necesita ningún flag especial. Si usas una versión entre la 22.5 y la 23.3, tendrás que arrancar tu programa connode --experimental-sqlite.
¿Por qué SQLite dentro de Node?
Como vimos en la lección de motores, SQLite es embebida: no hay servidor, ni puertos, ni usuarios. La base de datos es (opcionalmente) un solo archivo. Eso la convierte en la opción perfecta para aprender, para tests y para muchísimas aplicaciones reales.
Y lo mejor: desde hace poco Node incluye SQLite de serie. No necesitas npm install de nada.
TypeScript compatible con Node
Vamos a escribir el código en TypeScript, pero de una forma especial: TypeScript que Node puede ejecutar directamente, sin compilar.
Desde Node.js 23.6 (y de forma estable en Node 24), Node sabe ejecutar archivos .ts por sí mismo. Lo hace mediante type stripping: borra las anotaciones de tipos y ejecuta el JavaScript resultante. No hay tsc, ni dist/, ni configuración.
# Ejecutar un archivo TypeScript directamente con Node 24
node app.tsPara que el type stripping funcione hay un par de reglas que conviene recordar:
- Importa tipos con
import type, para que Node sepa que puede eliminarlos. - Evita características de TypeScript que generan código en tiempo de ejecución:
enum,namespacecon lógica, o los parameter properties del constructor. Usa alternativas planas (por ejemplo, un objetoconsten lugar de unenum). - Los tipos (
: string,interface,type) desaparecen al ejecutar, así que son “gratis”: te ayudan mientras programas y no molestan a Node.
// ✅ Compatible con Node: los tipos se borran al ejecutar
interface User {
id: number
name: string
}
function greet(user: User): string {
return `Hola, ${user.name}`
}El módulo node:sqlite
Todo gira en torno a una clase: DatabaseSync. La importamos del módulo nativo node:sqlite.
import { DatabaseSync } from "node:sqlite"El sufijo Sync significa síncrono: las operaciones se ejecutan en el momento y devuelven el resultado directamente, sin await. Para SQLite (que lee de un archivo local o de memoria) esto es rápido y muy cómodo.
En memoria o en disco: tú decides
Al crear la base de datos eliges dónde vive. Es tan simple como cambiar el texto que le pasas al constructor:
new DatabaseSync(":memory:")- Rapidísima
- Cero archivos
- Se borra al cerrar
new DatabaseSync("datos.db")- Persistente
- Un solo archivo
- Sobrevive al reinicio
import { DatabaseSync } from "node:sqlite"
// 🧠 En memoria: vive en la RAM y desaparece al cerrar el programa
const memoria = new DatabaseSync(":memory:")
// 💾 En disco: se guarda en un archivo y persiste entre ejecuciones
const disco = new DatabaseSync("datos.db"):memory:crea una base de datos que solo existe mientras tu programa está en marcha. Es ideal para tests (cada test arranca con una base limpia) y para prototipos rápidos.- Una ruta de archivo (
"datos.db","./db/app.db"…) crea o abre ese archivo en tu disco. Todo lo que insertes seguirá ahí la próxima vez que ejecutes el programa. Es lo que querrás en una aplicación real.
El resto del código es exactamente igual en ambos casos. Cambias una línea y decides si tus datos son efímeros o permanentes.
Crear una tabla
Para ejecutar SQL que no devuelve filas (como CREATE TABLE o INSERT), usamos db.exec(). Acepta una o varias sentencias separadas por ;.
import { DatabaseSync } from "node:sqlite"
const db = new DatabaseSync("biblioteca.db")
db.exec(`
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT NOT NULL,
year INTEGER,
read INTEGER NOT NULL DEFAULT 0
)
`)Reconoces esta sintaxis: es el mismo CREATE TABLE que aprendiste en el curso. La diferencia es que ahora lo ejecuta tu programa, no el playground.
Insertar con sentencias preparadas
Nunca metas valores directamente en el texto del SQL: es la puerta de entrada a la inyección SQL. En su lugar, se usan sentencias preparadas con marcadores ?, y los valores se pasan aparte.
const insert = db.prepare(
"INSERT INTO books (title, author, year) VALUES (?, ?, ?)"
)
// Cada ? se sustituye, en orden, por los argumentos de run()
const resultado = insert.run("SQL para humanos", "midudev", 2025)
console.log(resultado.lastInsertRowid) // 1 → el id generado
console.log(resultado.changes) // 1 → filas afectadasdb.prepare() “compila” la consulta una sola vez y devuelve un objeto que puedes reutilizar todas las veces que quieras. Es más rápido y más seguro.
// Reutilizamos la misma sentencia para insertar varios libros
const libros = [
["El Quijote", "Cervantes", 1605],
["1984", "Orwell", 1949],
["Fahrenheit 451", "Bradbury", 1953],
]
for (const [title, author, year] of libros) {
insert.run(title, author, year)
}Consultar datos
Una sentencia SELECT preparada tiene tres formas de leer resultados:
const query = db.prepare("SELECT * FROM books")
query.all() // → array con TODAS las filas
query.get() // → solo la PRIMERA fila (o undefined)
query.iterate() // → un iterador para recorrer fila a filaCada fila es un objeto normal de JavaScript, con las columnas como propiedades:
const books = db.prepare("SELECT * FROM books").all()
for (const book of books) {
console.log(`${book.title} (${book.year}) — ${book.author}`)
}
// El Quijote (1605) — Cervantes
// 1984 (1949) — Orwell
// ...Filtrar con parámetros
Los marcadores ? también funcionan en el WHERE. Aquí recuperamos solo los libros posteriores a un año:
const recientes = db
.prepare("SELECT title, year FROM books WHERE year > ? ORDER BY year DESC")
.all(1900)
console.log(recientes)
// [ { title: 'Fahrenheit 451', year: 1953 }, { title: '1984', year: 1949 } ]Si prefieres nombrar los parámetros en lugar de usar ?, puedes hacerlo con :nombre y pasar un objeto:
const porAutor = db.prepare(
"SELECT * FROM books WHERE author = :author"
)
porAutor.all({ author: "Orwell" })La inyección SQL: el ataque más típico
Hemos insistido en usar ? en lugar de meter los valores dentro del texto del SQL. Ahora toca ver por qué, porque detrás está una de las vulnerabilidades más famosas y peligrosas del desarrollo web: la inyección SQL (SQL injection).
Imagina que buscas un libro por su título construyendo el SQL a mano, pegando texto (⚠️ la forma incorrecta):
// ❌ NUNCA hagas esto: construir SQL concatenando la entrada del usuario
const titulo = obtenerBusquedaDelUsuario() // p. ej. viene de un formulario
const sql = `SELECT * FROM books WHERE title = '${titulo}'`
db.prepare(sql).all()Si el usuario busca El Quijote, la consulta queda bien:
SELECT * FROM books WHERE title = 'El Quijote';Pero ¿qué pasa si un atacante escribe esto como “título”?
' OR '1'='1El SQL resultante se transforma en:
SELECT * FROM books WHERE title = '' OR '1'='1';Como '1'='1' es siempre verdadero, la condición se cumple para todas las filas: el atacante se acaba de saltar el filtro y se lleva la tabla entera. Con la misma técnica se puede saltar un login (WHERE user = '...' AND password = '...'), leer datos de otras tablas o, en casos graves, encadenar '; DROP TABLE books; -- y destruir tus datos.
' OR '1'='1 SELECT * FROM books WHERE title = '' OR '1'='1' ? ' OR '1'='1 SELECT * FROM books WHERE title = ? valor = "' OR '1'='1" El problema de raíz es que el valor del usuario se mezcla con el código SQL: la base de datos no puede distinguir qué parte es una instrucción y qué parte es un dato.
La solución: sentencias preparadas
Las sentencias preparadas que ya conoces resuelven esto de raíz. Con ?, el valor viaja por un canal separado del SQL, y la base de datos lo trata siempre como un dato, nunca como código:
// ✅ Seguro: el valor jamás se interpreta como SQL
const titulo = obtenerBusquedaDelUsuario()
db.prepare("SELECT * FROM books WHERE title = ?").all(titulo)Aunque el atacante escriba ' OR '1'='1, la base de datos buscará literalmente un libro cuyo título sea ese texto raro (y no encontrará ninguno). El ataque deja de funcionar.
La regla de oro: los datos que vienen de fuera (formularios, URLs, APIs, cabeceras…) nunca se concatenan dentro del SQL. Siempre van como parámetros con
?(o:nombre). Es la defensa número uno contra la inyección SQL, y funciona igual en SQLite, MySQL, PostgreSQL o cualquier otro motor.
Un matiz importante: los marcadores ? sirven para valores, no para nombres de tablas o columnas. Esto no funciona:
// ❌ No puedes parametrizar un nombre de columna con ?
db.prepare("SELECT * FROM books ORDER BY ? ").all(columna)Si necesitas que el nombre de una columna o de la dirección de orden sea dinámico, valídalo contra una lista blanca de valores permitidos antes de insertarlo en el SQL:
// ✅ Solo permitimos ordenar por columnas conocidas
const columnasValidas = ["title", "author", "year"]
const columna = columnasValidas.includes(entrada) ? entrada : "title"
db.prepare(`SELECT * FROM books ORDER BY ${columna}`).all()Actualizar y borrar
UPDATE y DELETE funcionan igual que INSERT: se preparan y se ejecutan con .run(), devolviendo cuántas filas se vieron afectadas.
// Marcar un libro como leído
const marcarLeido = db.prepare("UPDATE books SET read = 1 WHERE id = ?")
marcarLeido.run(2)
// Borrar los libros no leídos anteriores a 1950
const limpiar = db.prepare("DELETE FROM books WHERE read = 0 AND year < ?")
const { changes } = limpiar.run(1950)
console.log(`${changes} libros eliminados`)Transacciones
Recuerda las transacciones: un grupo de operaciones que se aplican todas o ninguna. En Node las ejecutas con exec(), envolviendo tus operaciones entre BEGIN y COMMIT.
db.exec("BEGIN")
try {
insert.run("Dune", "Herbert", 1965)
insert.run("Neuromante", "Gibson", 1984)
db.exec("COMMIT") // confirmamos ambas
} catch (error) {
db.exec("ROLLBACK") // si algo falla, deshacemos las dos
throw error
}Una aplicación completa
Juntemos todo en un único archivo biblioteca.ts que puedes ejecutar con node biblioteca.ts:
import { DatabaseSync } from "node:sqlite"
interface Book {
id: number
title: string
author: string
year: number
read: number
}
// 💾 En disco: los datos persisten entre ejecuciones.
// Cambia "biblioteca.db" por ":memory:" para una base efímera.
const db = new DatabaseSync("biblioteca.db")
db.exec(`
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT NOT NULL,
year INTEGER,
read INTEGER NOT NULL DEFAULT 0
)
`)
const insert = db.prepare(
"INSERT INTO books (title, author, year) VALUES (?, ?, ?)"
)
insert.run("SQL para humanos", "midudev", 2025)
insert.run("1984", "Orwell", 1949)
const books = db.prepare("SELECT * FROM books ORDER BY year").all() as Book[]
for (const book of books) {
console.log(`📚 ${book.title} — ${book.author} (${book.year})`)
}
// Siempre cerramos la conexión al terminar
db.close()Al ejecutarlo verás los libros por consola, y si abriste una base en disco encontrarás un archivo biblioteca.db junto a tu código. Ese único archivo es tu base de datos: puedes copiarlo, versionarlo o abrirlo con cualquier herramienta de SQLite.
Recuerda cerrar con
db.close()cuando termines. En una base en disco esto asegura que todo quede correctamente escrito en el archivo.
¿Y si uso una versión antigua de Node?
Si trabajas en un proyecto con una versión de Node anterior a la 22.5, la alternativa clásica es el paquete better-sqlite3, muy popular y con una API casi idéntica:
npm install better-sqlite3import Database from "better-sqlite3"
const db = new Database("biblioteca.db") // o new Database(":memory:")
db.prepare("SELECT * FROM books").all()Como ves, los conceptos son los mismos: prepare, run, all. Si aprendes node:sqlite, better-sqlite3 te resultará familiar al instante.
Practica el SQL de esta lección
El código de Node solo ejecuta SQL; lo importante sigue siendo saber escribir buenas consultas. Practica sobre la misma tabla books que hemos usado en los ejemplos:
Ejercicios
En resumen
- Node incluye SQLite de serie con el módulo
node:sqlite(desde Node 22.5; recomendamos Node 24 LTS). - Puedes ejecutar TypeScript directamente con
node app.ts, usando tipos que Node borra al ejecutar. new DatabaseSync(":memory:")crea una base en memoria (efímera, perfecta para tests); pasar una ruta de archivo la guarda en disco y la hace persistente.db.exec()ejecuta SQL sin resultados;db.prepare()crea sentencias reutilizables con.run(),.get(),.all()e.iterate().- Usa siempre marcadores
?(o:nombre) para pasar valores y evitar la inyección SQL.