All'inizio un file JSON basta, ed è la scelta giusta: nessuna dipendenza, si legge con un editor di testo, si versiona con il resto del progetto. Il problema è che il momento in cui smette di bastare non è annunciato da un errore — è annunciato da un cliente che chiede dove sia finito il suo ordine.

Il bug che non vedrete nei log#

ordini.jsJavaScript
// Il bug del file JSON, che non è "il file si corrompe" ma qualcosa di// più insidioso: due scritture concorrenti, e una sparisce in silenzio.////   t0  richiesta A  legge  ordini.json  → [1, 2, 3]//   t1  richiesta B  legge  ordini.json  → [1, 2, 3]//   t2  richiesta A  scrive [1, 2, 3, A]//   t3  richiesta B  scrive [1, 2, 3, B]   ← l'ordine A non esiste più//// Nessun errore, nessun log, nessuna eccezione. Il cliente A ha ricevuto// la conferma d'ordine, e nel file il suo ordine non c'è.//// Node è a thread singolo, il che fa credere di essere al sicuro. Non lo// si è: fra la lettura e la scrittura c'è un await, e durante un await// il ciclo degli eventi serve altre richieste.const ordini = JSON.parse(await fs.readFile(FILE, 'utf8'));   // ← awaitordini.push(nuovo);await fs.writeFile(FILE, JSON.stringify(ordini));             // ← await

Il malinteso da chiarire subito: «Node è a thread singolo, quindi non c'è concorrenza». Node esegue una sola istruzione JavaScript alla volta, ma await restituisce il controllo al ciclo degli eventi — e in quel momento il server accetta e serve altre richieste. Fra la lettura del file e la sua riscrittura passano decine di millisecondi durante i quali chiunque altro può fare esattamente la stessa cosa.

Il risultato è la perdita di aggiornamento classica: entrambe le richieste leggono lo stesso stato, entrambe scrivono, la seconda cancella il lavoro della prima. Non c'è alcuna eccezione, nessun log, nessun avviso. Il cliente ha già ricevuto la conferma via email.

atomico.jsJavaScript
// Il secondo modo, più raro e più grave: writeFile TRONCA il file e poi// scrive. Se il processo muore in mezzo — OOM killer, riavvio, disco// pieno — resta un file JSON troncato, cioè illeggibile. Non parziale:// ILLEGGIBILE, perché manca la parentesi finale.// ✓ Se proprio si deve restare su file: scrittura atomica.//   Si scrive su un file temporaneo, si forza il flush su disco, e si//   rinomina. rename() su POSIX è atomico: o c'è il vecchio o il nuovo.async function scriviAtomico(percorso, dati) {  const tmp = percorso + '.tmp.' + process.pid;  const fd = await fs.open(tmp, 'w');  try {    await fd.writeFile(JSON.stringify(dati));    await fd.sync();               // senza questo, il rename può precedere  } finally {                      // la scrittura effettiva sul disco    await fd.close();  }  await fs.rename(tmp, percorso);}// Resta comunque da risolvere la concorrenza (serve un lock) e resta// tutto il resto: ricerche lineari, nessun vincolo, nessuna transazione.// A questo punto si sta scrivendo un database, male.

Si può fare, e per una configurazione o un contatore va benissimo. Ma resta scoperta la concorrenza (serve un lock), restano le ricerche lineari, restano l'assenza di vincoli e di transazioni. A quel punto si sta scrivendo un database — male, e senza test.

SQLite: un database in un file#

schema.sqlSQL
-- SQLite: un database completo in un file, senza server da installare.-- Le tre cose che il JSON non poteva dare cominciano dallo schema.CREATE TABLE ordini (  id          INTEGER PRIMARY KEY,  cliente_id  INTEGER NOT NULL REFERENCES clienti(id) ON DELETE RESTRICT,  stato       TEXT    NOT NULL CHECK (stato IN ('bozza','pagato','spedito')),  totale_cent INTEGER NOT NULL CHECK (totale_cent >= 0),  creato_il   TEXT    NOT NULL DEFAULT (datetime('now'))) STRICT;-- STRICT (dalla 3.37): senza, SQLite accetta una stringa in una colonna-- INTEGER. Con, il tipo è un vincolo vero. Va messo sempre.-- I vincoli sulle chiavi esterne NON sono attivi per impostazione-- predefinita: vanno accesi a ogni connessione, o non fanno nulla.PRAGMA foreign_keys = ON;CREATE INDEX idx_ordini_cliente ON ordini(cliente_id, creato_il DESC);

Due caratteristiche di SQLite vale la pena conoscerle prima, perché sono controintuitive e silenziose.

STRICT. Senza questa parola chiave SQLite accetta una stringa in una colonna dichiarata INTEGER: i tipi sono suggerimenti, non vincoli. È un'eredità storica, ed è disponibile dalla versione 3.37 il modo per chiedere il comportamento che tutti si aspettano. Va messo sempre.

PRAGMA foreign_keys. I vincoli di integrità referenziale sono disattivati per impostazione predefinita, sempre per compatibilità. Uno schema pieno di REFERENCES non impone nulla finché non li si accende — e vanno accesi a ogni connessione, non una volta sul file.

Le impostazioni che cambiano tutto#

db.jsJavaScript
// Le impostazioni senza cui SQLite sembra lento o si blocca. Vanno// eseguite all'apertura della connessione, in quest'ordine.const db = new Database('dati.db');// 1. WAL: letture concorrenti MENTRE una scrittura è in corso.//    Senza, ogni scrittura blocca tutti i lettori. È la differenza fra//    "SQLite non regge il traffico" e "SQLite regge benissimo".db.pragma('journal_mode = WAL');// 2. Attesa invece di errore immediato quando il database è occupato.//    Senza, sotto concorrenza si ricevono SQLITE_BUSY sporadici.db.pragma('busy_timeout = 5000');// 3. Vincoli di integrità referenziale attivi.db.pragma('foreign_keys = ON');// 4. Compromesso durabilità/velocità: in WAL, NORMAL è sicuro rispetto//    ai crash dell'applicazione; si perde al massimo l'ultima transazione//    in caso di crash del SISTEMA OPERATIVO.db.pragma('synchronous = NORMAL');

La modalità WAL è la più importante. Nella modalità predefinita ogni scrittura blocca tutti i lettori: è da lì che nasce la convinzione che SQLite «non regga il traffico». In WAL le letture procedono mentre una scrittura è in corso, e il profilo tipico di un'applicazione web — molte letture, poche scritture — diventa perfettamente sostenibile su una macchina sola.

La transazione, cioè il motivo per cui si migra#

ordine.jsJavaScript
// La proprietà che il file non poteva dare: o tutto, o niente.const creaOrdine = db.transaction((cliente, righe) => {  const { lastInsertRowid: ordineId } = inserisciOrdine.run({    cliente_id: cliente.id, stato: 'bozza', totale_cent: 0  });  let totale = 0;  for (const r of righe) {    // Se la giacenza non basta, questa UPDATE non tocca righe...    const res = scalaGiacenza.run({ id: r.prodottoId, q: r.quantita });    if (res.changes === 0) throw new Error('giacenza insufficiente');    // ...e l'eccezione annulla ANCHE l'ordine e le righe già inserite.    inserisciRiga.run({ ordine_id: ordineId, ...r });    totale += r.prezzo_cent * r.quantita;  }  aggiornaTotale.run({ id: ordineId, totale_cent: totale });  return ordineId;});// Con il file JSON questo scenario lasciava lo stock scalato e nessun// ordine, oppure l'ordine senza righe. Sono i "dati sporchi" che poi// qualcuno corregge a mano il venerdì sera.

Questa è la proprietà che il file non poteva dare in nessun modo: o tutto, o niente. Se la giacenza non basta a metà dell'ordine, non resta uno stock scalato senza ordine né un ordine senza righe. Sono esattamente i «dati sporchi» che qualcuno finisce per correggere a mano il venerdì sera, e che nessuno riesce a spiegare perché nei log non c'è niente.

Drizzle: lo schema come fonte dei tipi#

schema.tsJavaScript
// Drizzle: lo schema è TypeScript, e i tipi delle query si deducono// da lì. Non c'è un tipo scritto a mano che possa divergere dalla tabella.import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';import { eq, and, desc, sql } from 'drizzle-orm';export const ordini = sqliteTable('ordini', {  id:         integer('id').primaryKey(),  clienteId:  integer('cliente_id').notNull().references(() => clienti.id),  stato:      text('stato', { enum: ['bozza','pagato','spedito'] }).notNull(),  totaleCent: integer('totale_cent').notNull(),  creatoIl:   text('creato_il').notNull()});// La query è tipizzata: sbagliare un nome di colonna è un errore di// compilazione, non una riga vuota alle due di notte.const recenti = await db  .select({ id: ordini.id, totale: ordini.totaleCent })  .from(ordini)  .where(and(eq(ordini.clienteId, id), eq(ordini.stato, 'pagato')))  .orderBy(desc(ordini.creatoIl))  .limit(20);//        └─ eq(ordini.stato, 'spedto')  → errore: 'spedto' non è nell'enum

Il motivo per cui preferisco Drizzle ad alternative più astratte è che non nasconde SQL: la query che si scrive assomiglia alla query che viene eseguita, quindi quando qualcosa è lento si sa dove guardare. E i tipi si deducono dallo schema, il che elimina la classe di errori in cui un'interfaccia scritta a mano diverge dalla tabella dopo una migrazione — vedi anche l'articolo su dove i tipi ripagano.

migrazioni
# Le migrazioni sono file SQL versionati, generati dallo schema.npx drizzle-kit generate✓ Your SQL migration file ➜ drizzle/0003_aggiungi_stato_ordine.sqlcat drizzle/0003_aggiungi_stato_ordine.sqlALTER TABLE ordini ADD COLUMN stato TEXT NOT NULL DEFAULT 'bozza';# Il file si LEGGE prima di applicarlo. Una migrazione generata non e'# una migrazione corretta: su tabelle grandi puo' bloccare, e su SQLite# certe modifiche richiedono di ricreare la tabella.npx drizzle-kit migrate
Una migrazione generata non è una migrazione corretta: il file va letto prima di applicarlo, soprattutto su SQLite, dove certe modifiche di colonna richiedono di ricreare la tabella.

Portare dentro i dati vecchi#

importa.jsJavaScript
// Portare dentro il JSON esistente: una volta sola, dentro una// transazione, con la validazione che il file non aveva mai avuto.const vecchi = JSON.parse(fs.readFileSync('ordini.json', 'utf8'));const importa = db.transaction((righe) => {  let ok = 0, scartati = [];  for (const r of righe) {    const v = SchemaOrdine.safeParse(r);      // zod: qui emergono i dati    if (!v.success) { scartati.push({ r, e: v.error.issues }); continue; }    inserisci.run(v.data);    ok++;  }  return { ok, scartati };});const esito = importa(vecchi);// I record scartati NON vanno ignorati: sono le incoerenze che il file// tollerava in silenzio. Vanno guardati uno per uno — è quasi sempre// il momento in cui si scopre che tre ordini non avevano un cliente.fs.writeFileSync('scartati.json', JSON.stringify(esito.scartati, null, 2));

Questo passaggio è più istruttivo di quanto sembri. Il file JSON tollerava qualunque cosa: record senza un campo obbligatorio, riferimenti a clienti cancellati, date in tre formati diversi, importi salvati a volte come numero e a volte come stringa. Al primo INSERT con vincoli attivi, tutto questo emerge insieme.

La tentazione è rilassare i vincoli per far passare l'importazione. È la scelta sbagliata: quei record scartati sono il problema, e sono lì da mesi. Vanno esaminati uno per uno — è quasi sempre il momento in cui si scopre che tre ordini non avevano un cliente valido, e che nessuno se n'era accorto.

Il backup non è una copia#

backup
# Il backup di un file SQLite NON si fa copiando il file: in WAL,# una copia fatta durante una scrittura e' incoerente.sqlite3 dati.db ".backup '/backup/dati-$(date +%F).db'"# Oppure, senza uscire dall'applicazione:#   db.backup('/backup/dati.db')     // better-sqlite3, online e sicuro# E la verifica, che vale piu' del backup stesso:sqlite3 /backup/dati-2026-08-23.db 'PRAGMA integrity_check;'ok# Un backup mai ripristinato non e' un backup: e' una speranza.
In modalità WAL i dati vivono in due file: copiare solo il .db produce un backup incoerente. L'API di backup di SQLite gestisce il caso correttamente, anche a database aperto.

Quando restare sul file#

SituazioneSceltaPerché
Configurazione, elenchi statici, contenutifile JSONscritti dal deploy, letti a runtime: nessuna concorrenza
Dati scritti da più richiesteSQLitela scrittura concorrente perde dati in silenzio
Vincoli fra entità (ordini → clienti)SQLitenessun file può imporre integrità referenziale
Ricerche e filtri su molti recordSQLiteun indice contro una scansione completa a ogni richiesta
Operazioni che devono riuscire o fallire insiemeSQLitele transazioni non si simulano
Scritture sostenute e concorrentiPostgreSQLSQLite ammette una scrittura per volta
Più macchine sullo stesso archivioPostgreSQLSQLite è un file locale, non un servizio di rete

Il criterio in una riga: finché a scrivere è il deploy, il file va bene; quando a scrivere sono gli utenti, serve un database. E vale la pena fare il passaggio quando i dati sono ancora pochi — la migrazione costa un pomeriggio con cento record e una settimana con centomila, con la differenza che nel secondo caso ci sono già anni di incoerenze da districare.