# Safe migrations in production

On 200,000 rows SQLite adds a column in 0.17 ms and rebuilds the table in 243.4 ms; between those numbers sits the lock a user waits on.

> Page: https://ui.eneawork.it/blog/migrazioni-database-sicure
> Markdown: https://ui.eneawork.it/blog/migrazioni-database-sicure.md

*2026-11-01*

Una migrazione è sicura quando sai per quanto tempo tiene il lock, non quando il comando è breve. Su 200.000 righe, SQLite 3.51.3 aggiunge una colonna in 0,64 ms e ricostruisce la tabella in 243,4 ms: fra i due numeri c'è la differenza fra un metadata update e una copia completa.

Tabella orders con 200.000 righe e un indice, SQLite 3.51.3 su Node 24.15.0, 1 novembre 2026: ALTER TABLE orders ADD COLUMN archived INTEGER NOT NULL DEFAULT 0 ha impiegato 0,17 ms e PRAGMA page_count è rimasto 3.847 prima e dopo. La ricetta in dodici passi, con tabella nuova, INSERT ... SELECT, DROP e RENAME, ha impiegato 243,4 ms e ha conservato 200.000 righe. Il catalogo EneUI conta 111.985 componenti, 1.556 blocchi e 12.808 icone da 1.472 librerie, letti da /api/stats lo stesso giorno.

## Cosa fa davvero un ALTER TABLE

Aggiungere una colonna nullable non tocca le righe: il database scrive una riga di catalogo e lascia i valori assenti. PostgreSQL 11, pubblicato il 18 ottobre 2018, ha esteso la stessa idea al caso con NOT NULL DEFAULT costante; prima di quella versione la tabella veniva riscritta. SQLite si comporta allo stesso modo e il conteggio delle pagine lo dimostra: 3.847 prima, 3.847 dopo.

## Tre classi, tre rischi

La prima classe è metadata: aggiungere una colonna, rinominare un oggetto, creare un indice su una tabella vuota. La seconda è riscrittura: cambiare il tipo di una colonna, aggiungere un CHECK, togliere il NULL. La terza è backfill: un UPDATE su milioni di righe, che scrive tanto e genera WAL. Le prime due si misurano in laboratorio, la terza si misura in produzione e va spezzata in lotti.

## Il comando che dice se la tabella viene riscritta

Il conteggio delle pagine è il segnale più semplice e non richiede un ambiente di prova: se cresce, il database ha copiato i dati. Esegui il confronto su una copia del database, non su quello vero.

node -e "const {DatabaseSync}=require('node:sqlite');const db=new DatabaseSync('app.db');const before=db.prepare('PRAGMA page_count').get().page_count;const t=process.hrtime.bigint();db.exec('ALTER TABLE orders ADD COLUMN archived INTEGER NOT NULL DEFAULT 0');console.log({ms:Number(process.hrtime.bigint()-t)/1e6,before,after:db.prepare('PRAGMA page_count').get().page_count})"

## La ricetta expand, backfill, contract

Per cambiare una colonna senza fermare il servizio si lavora in tre release. Nella prima aggiungi la colonna nuova, nullable, e il codice scrive su entrambe. Nella seconda copi i dati a lotti, con un ordine stabile e una pausa fra i lotti. Nella terza il codice legge solo la colonna nuova, aggiungi il vincolo e rimuovi la vecchia. Ogni passo è reversibile e nessuno tiene un lock lungo.

## Il limite: la copia non è gratuita

I 243,4 ms misurati valgono per 200.000 righe con la cache calda: su una tabella da cinquanta milioni di righe la stessa ricetta dura minuti e SQLite non ha DDL online, quindi il lock di scrittura resta chiuso per tutta la copia. PostgreSQL permette CREATE INDEX CONCURRENTLY, ma un ALTER TABLE che cambia il tipo richiede comunque un lock esclusivo, breve solo se la tabella è piccola. Nessuno strumento di migrazione cambia questa aritmetica.

## Da dove partire

Misura il conteggio delle pagine su una copia prima di ogni migrazione che sembra innocua.,Spezza i backfill in lotti da poche migliaia di righe, con ordine stabile e pausa fra i lotti.,Applica i vincoli in due tempi, come NOT VALID e poi VALIDATE CONSTRAINT in PostgreSQL.,Controlla cosa genera il tuo ORM: Prisma e Drizzle producono SQL diverso per lo stesso cambio di schema.,Aggiorna il contratto dell'API nello stesso rilascio della migrazione: le regole di progetto valgono anche per un campo nuovo.,Leggi il codice come lo legge il database: la vista tabella, i blocchi dashboard e il catalogo form aiutano a mostrare lo stato della migrazione, e le transazioni ACID spiegano perché un rollback non è gratis.

## FAQ

### Aggiungere una colonna con DEFAULT riscrive la tabella?

No, se il valore è una costante e il database è aggiornato. PostgreSQL lo fa dal 11, SQLite da sempre: il conteggio delle pagine resta identico. Prima di PostgreSQL 11 la tabella veniva riscritta per intero.

### Posso eliminare una colonna senza ricostruire la tabella?

Dipende dalla versione. SQLite supporta DROP COLUMN dalla 3.35 e PostgreSQL da sempre, con un lock esclusivo breve. Se la colonna è coinvolta in un indice o in un vincolo, serve comunque la ricetta con tabella nuova.

### Come faccio un backfill senza bloccare le scritture?

A lotti, con un ordine stabile e una pausa fra un lotto e l'altro. Aggiorna qualche migliaio di righe per volta, controlla il ritardo di replica e interrompi se il lock di scrittura cresce.

### Meglio scrivere le migrazioni a mano o generarle?

Generale con lo strumento e rileggile prima di applicarle. Prisma e drizzle-kit producono SQL corretto ma non conoscono il volume della tua tabella, e il volume decide se la migrazione è metadata o riscrittura.

### Cosa faccio se una migrazione va male?

Torna al backup e riparti dal passo precedente della sequenza expand, backfill, contract. Il rollback di un ALTER TABLE non ripristina i dati già copiati, quindi ogni passo deve restare compatibile con il codice in esecuzione.
