PostgreSQL JSONB vs tabelle normalizzate: come decidere e migrare
PostgreSQL JSONB vs tabelle normalizzate: un framework pratico per scegliere nei prototipi e un percorso di migrazione sicuro man mano che l'app cresce.

Il problema reale: muoversi in fretta senza mettersi in trappola
È normale avere requisiti che cambiano ogni settimana quando stai costruendo qualcosa di nuovo. Un cliente chiede un campo in più. Sales vuole un workflow diverso. Support ha bisogno di una cronologia. Il database finisce per portare il peso di tutti questi cambiamenti.
Iterare velocemente non significa solo consegnare schermate più in fretta. Significa poter aggiungere, rinominare e rimuovere campi senza rompere report, integrazioni o record vecchi. Significa anche poter rispondere a nuove domande (per esempio: 'Quanti ordini hanno avuto note di consegna mancanti il mese scorso?') senza trasformare ogni query in uno script ad-hoc.
Per questo la scelta tra JSONB e tabelle normalizzate conta fin da subito. Entrambe possono funzionare e entrambe possono creare dolore se usate per il lavoro sbagliato. JSONB dà l'impressione di libertà perché oggi puoi salvare quasi tutto. Le tabelle normalizzate danno una sensazione di sicurezza perché impongono struttura. L'obiettivo reale è far combaciare il modello di memorizzazione con quanto i tuoi dati sono incerti ora e quanto velocemente devono diventare affidabili.
Quando i team scelgono il modello sbagliato, i sintomi sono di solito evidenti:
- Domande semplici diventano query lente e disordinate o codice personalizzato.
- Due record rappresentano la stessa cosa ma usano nomi di campo diversi.
- Campi opzionali diventano obbligatori dopo, e i dati vecchi non corrispondono.
- Non puoi imporre regole (valori unici, relazioni obbligatorie) senza stratagemmi.
- Report ed esportazioni si rompono dopo piccoli cambiamenti.
La decisione pratica è questa: dove ti serve flessibilità (e puoi tollerare inconsistenza per un po') e dove ti serve struttura (perché i dati guidano soldi, operazioni o conformità)?
JSONB e tabelle normalizzate, spiegate semplicemente
PostgreSQL può memorizzare dati in colonne classiche (text, number, date). Può anche memorizzare un intero documento JSON dentro una colonna usando JSONB. La differenza non è 'nuovo vs vecchio'. È cosa vuoi che il database garantisca.
JSONB salva chiavi, valori, array e oggetti annidati. Non impone automaticamente che ogni riga abbia le stesse chiavi, che i valori abbiano sempre lo stesso tipo, o che un elemento referenziato esista in un'altra tabella. Puoi aggiungere controlli, ma devi decidere e implementarli.
Le tabelle normalizzate significano dividere i dati in tabelle separate per quel che sono e collegarle con ID. Un cliente è in una tabella, un ordine in un'altra, e ogni ordine punta a un cliente. Questo ti dà una protezione più forte contro contraddizioni.
Nel lavoro quotidiano i compromessi sono semplici:
- JSONB: flessibile per default, facile da cambiare, più semplice scivolare nell'inconsistenza.
- Tabelle normalizzate: più intenzionali da cambiare, più facili da validare, più semplici da interrogare in modo coerente.
Un esempio semplice sono i campi personalizzati di un ticket di supporto. Con JSONB puoi aggiungere un campo nuovo domani senza migrazione. Con tabelle normalizzate aggiungere un campo è più intenzionale, ma reporting e regole sono più chiare.
Quando JSONB è lo strumento giusto per iterare in fretta
JSONB è una buona scelta quando il rischio più grande è costruire la forma sbagliata dei dati, non imporre regole rigide. Se il tuo prodotto sta ancora trovando il suo workflow, forzare tutto dentro tabelle fisse può rallentarti con migrazioni costanti.
Un buon segnale è quando i campi cambiano settimanalmente. Pensa a un form di onboarding dove marketing continua ad aggiungere domande, rinominare etichette e rimuovere passi. JSONB ti permette di memorizzare ogni submission così com'è, anche se la versione di domani sarà diversa.
JSONB si adatta anche agli 'sconosciuti': dati che non comprendi del tutto o che non controlli. Se ricevi payload webhook da partner, salvare il payload raw in JSONB ti consente di supportare nuovi campi subito e decidere dopo cosa promuovere a colonne 'di prima classe'.
Usi comuni nelle prime fasi includono form in rapido cambiamento, cattura eventi e log di audit, impostazioni per cliente, feature flag e esperimenti. È particolarmente utile quando principalmente scrivi i dati, li leggi come blocco e la forma è ancora in movimento.
Una regola semplice aiuta più di quanto la gente si aspetti: tieni una breve nota condivisa delle chiavi che stai usando così non ti ritrovi con cinque varianti ortografiche dello stesso campo tra le righe.
Quando le tabelle normalizzate sono la scelta più sicura a lungo termine
Le tabelle normalizzate vincono quando i dati smettono di essere 'solo per questa feature' e diventano condivisi, interrogati e affidabili. Se le persone taglieranno e filtreranno i record in molti modi (status, owner, regione, periodo di tempo), colonne e relazioni rendono il comportamento prevedibile e più facile da ottimizzare.
La normalizzazione conta anche quando le regole devono essere imposte dal database, non da codice applicativo 'a buon fine'. JSONB può contenere qualsiasi cosa, ed è proprio questo il problema quando servono garanzie forti.
Segnali che dovresti normalizzare ora
Di solito è tempo di allontanarsi da un modello JSON-first quando più di questi sono veri:
- Ti servono report e dashboard coerenti.
- Ti servono vincoli come campi obbligatori, valori unici o relazioni con altre entità.
- Più di un servizio o team legge e scrive gli stessi dati.
- Le query iniziano a scandire molte righe perché non possono utilizzare indici semplici.
- Sei in un ambiente regolamentato o sottoposto ad audit e le regole devono essere dimostrabili.
La performance è un punto di svolta comune. Con JSONB filtrare spesso significa estrarre valori ripetutamente. Puoi indicizzare percorsi JSON, ma i requisiti tendono a crescere in una collezione di indici difficile da mantenere.
Un esempio concreto
Un prototipo memorizza 'customer requests' come JSONB perché ogni tipo di richiesta ha campi diversi. Più avanti, operations ha bisogno di una coda filtrata per priorità e SLA. Finance chiede totali per reparto. Support deve garantire che ogni richiesta abbia un customer_id e uno status. Qui le tabelle normalizzate brillano: colonne chiare per campi comuni, chiavi esterne verso clienti e team, e vincoli che impediscono l'ingresso di dati scorretti.
Un semplice framework decisionale che puoi usare in 30 minuti
Non serve un grande dibattito sulla teoria dei database. Serve una risposta rapida e scritta a una domanda: dove la flessibilità vale più della struttura rigida?
Fallo con le persone che costruiscono e usano il sistema (builder, ops, support e magari finance). L'obiettivo non è scegliere un vincitore unico. È scegliere la corrispondenza giusta per ogni parte del prodotto.
Checklist in 5 passi
-
Elenca le 10 schermate più importanti e le domande esatte dietro di esse. Esempi: 'aprire una scheda cliente', 'trovare ordini scaduti', 'esportare i pagamenti del mese scorso'. Se non riesci a nominare la domanda, non puoi progettarla.
-
Evidenzia i campi che devono essere corretti sempre. Queste sono regole dure: status, importi, date, ownership, permessi. Se un valore sbagliato costerebbe soldi o scatena un incendio di supporto, di solito appartiene a colonne normali con vincoli.
-
Segna cosa cambia spesso vs raramente. Cambi settimanali (nuove domande nei form, dettagli partner) sono forti candidati per JSONB. I campi 'core' che cambiano raramente tendono alla normalizzazione.
-
Decidi cosa deve essere ricercabile, filtrabile o ordinabile nell'interfaccia. Se gli utenti filtrano di continuo su quel campo, di solito è meglio come colonna di prima classe (o come percorso JSONB indicizzato con cura).
-
Scegli un modello per area. Una separazione comune è tabelle normalizzate per entità e workflow core, più JSONB per extra e metadati in rapido cambiamento.
Fondamenti di performance senza perdersi nei dettagli
La velocità di solito deriva da una cosa: rendere le domande più comuni economiche da rispondere. Questo conta più dell'ideologia.
Se usi JSONB, tienilo piccolo e prevedibile. Qualche campo extra va bene. Un blob gigante e in continuo cambiamento è difficile da indicizzare e facile da usare male. Se sai che una chiave esisterà (come 'priority' o 'source'), mantieni il nome della chiave coerente e il tipo di valore coerente.
Gli indici non sono magia. Scambiano letture più veloci per scritture più lente e più spazio su disco. Indicizza solo ciò su cui filtri o fai join spesso, e solo nella forma in cui interroghi davvero.
Regole pratiche per l'indicizzazione
- Metti indici btree normali su filtri comuni come status, owner_id, created_at, updated_at.
- Usa un indice GIN su una colonna JSONB quando cerchi spesso al suo interno.
- Preferisci indici di espressione per uno o due campi JSON 'caldi' (per esempio (meta->>'priority')) invece di indicizzare l'intero JSONB.
- Usa indici parziali quando conta solo una fetta (per esempio solo le righe dove status = 'open').
Evita di memorizzare numeri e date come stringhe dentro JSONB. '10' viene prima di '2' in ordinamento stringa, e la manipolazione di date diventa dolorosa. Usa tipi numerici e timestamp reali nelle colonne, o almeno memorizza numeri JSON come numeri.
Spesso vince un modello ibrido: campi core in colonne, extra flessibili in JSONB. Esempio: una tabella operations con id, status, owner_id, created_at come colonne, più meta JSONB per risposte opzionali.
Errori comuni che creano dolore più avanti
JSONB può sembrare libertà all'inizio. Il dolore si vede di solito mesi dopo, quando più persone toccano i dati e 'quel che funziona' diventa 'non possiamo più cambiare senza rompere qualcosa'.
Questi pattern causano la maggior parte del lavoro di pulizia:
- Trattare JSONB come un deposito. Se ogni team salva forme leggermente diverse, finirai a scrivere parsing personalizzato ovunque. Stabilisci convenzioni di base: nomi di chiavi coerenti, formati di data chiari e un piccolo campo versione dentro il JSON.
- Nascondere entità core dentro JSONB. Memorizzare clienti, ordini o permessi solo come blob sembra semplice all'inizio, poi i join diventano scomodi, i vincoli difficili da applicare e compaiono duplicati. Tieni who/what/when in colonne e metti i dettagli opzionali in JSONB.
- Aspettare a pensare alla migrazione fino a che non è urgente. Se non tieni traccia delle chiavi esistenti, di come sono cambiate e quali sono 'ufficiali', la prima migrazione reale diventa rischiosa.
- Assumere che JSONB significhi automaticamente flessibilità e velocità. La flessibilità senza regole è solo inconsistenza. La velocità dipende dai pattern di accesso e dagli indici.
- Rovinare l'analitica cambiando le chiavi nel tempo. Rinominare status in state, passare numeri a stringhe o mischiare fusi orari rovinerà silenziosamente i report.
Un esempio concreto: un team parte con una tabella tickets e un campo details JSONB per le risposte del form. Più tardi finance vuole breakdown settimanali per categoria, operations vuole tracking SLA e support vuole dashboard 'aperti per team'. Se categorie e timestamp migrano tra chiavi e formati, ogni report diventa una query ad-hoc.
Un piano di migrazione quando il prototipo diventa mission-critical
Quando un prototipo inizia a gestire stipendi, inventario o supporto clienti, 'lo sistemiamo dopo' smette di essere accettabile. Il percorso più sicuro è migrare a piccoli passi, lasciando che i vecchi dati JSONB continuino a funzionare mentre la nuova struttura si dimostra.
Un approccio a fasi evita una riscrittura rischiosa in una sola volta:
- Progetta la destinazione prima. Scrivi le tabelle target, le chiavi primarie e le regole di naming. Decidi cosa è una vera entità (Customer, Ticket, Order) e cosa resta flessibile (note, attributi opzionali).
- Costruisci nuove tabelle accanto ai dati vecchi. Mantieni la colonna JSONB, aggiungi tabelle normalizzate e indici in parallelo.
- Backfilla a blocchi e convalida. Copia i campi JSONB nelle nuove tabelle in batch. Convalida con conteggi di righe, campi obbligatori non null e controlli spot.
- Cambia le letture prima delle scritture. Aggiorna query e report per leggere dalle nuove tabelle prima. Quando gli output combaciano, inizia a scrivere le modifiche nelle tabelle normalizzate.
- Metti tutto sotto controllo. Smetti di scrivere su JSONB, poi elimina o congela i campi vecchi. Aggiungi vincoli (foreign key, regole di unicità) così i dati scorretti non possano tornare.
Prima del cutover finale:
- Esegui entrambi i percorsi per una settimana (vecchio vs nuovo) e confronta gli output.
- Monitora query lente e aggiungi indici dove serve.
- Prepara un piano di rollback (feature flag o switch di config).
- Comunica all'intero team l'orario esatto del cambio delle scritture.
Controlli rapidi prima di impegnarti
Prima di consolidare l'approccio, fai un reality check. Queste domande catturano la maggior parte dei problemi futuri mentre il cambiamento è ancora economico.
Cinque domande che decidono la maggior parte degli esiti
- Abbiamo bisogno di unicità, campi obbligatori o tipi stretti ora (o nel prossimo rilascio)?
- Quali campi devono essere filtrabili e ordinabili per gli utenti (ricerca, status, owner, date)?
- Avremo bisogno di dashboard, esportazioni o report per finance/ops presto?
- Possiamo spiegare il modello dati a un nuovo collega in 10 minuti senza fare giri di parole?
- Qual è il nostro piano di rollback se una migrazione rompe un workflow?
Se rispondi 'sì' alle prime tre, sei già orientato verso tabelle normalizzate (o almeno un ibrido: campi core normalizzati, attributi long-tail in JSONB). Se l'unico 'sì' è l'ultimo, il problema più grande è il processo, non lo schema.
Una regola pratica semplice
Usa JSONB quando la forma dei dati è ancora poco chiara, ma puoi nominare un piccolo set di campi stabili che serviranno sempre (come id, owner, status, created_at). Nel momento in cui le persone dipendono da filtri coerenti, esportazioni affidabili o validazioni rigide, il costo della 'flessibilità' cresce rapidamente.
Esempio: da un form flessibile a un sistema operativo affidabile
Immagina un form di intake del supporto che cambia settimanalmente. Una settimana aggiungi 'device model', la successiva aggiungi 'refund reason', poi rinomini 'priority' in 'urgency'. All'inizio mettere il payload del form in una singola colonna JSONB sembra perfetto. Puoi rilasciare senza migrazione e nessuno si lamenta.
Tre mesi dopo i manager vogliono filtri come 'urgency = high e device model inizia con iPhone', SLA basate sul tier cliente e un report settimanale che deve corrispondere ai numeri della settimana precedente.
Il modo in cui fallisce è prevedibile: qualcuno chiede 'Dov'è finito questo campo?' I record più vecchi usavano un nome di chiave diverso, il tipo di valore è cambiato ('3' vs 3) o il campo non è mai esistito per metà dei ticket. I report diventano un patchwork di casi speciali.
Un compromesso pratico è il design ibrido: mantieni i campi stabili e critici per il business come colonne reali (created_at, customer_id, status, urgency, sla_due_at), e conserva un'area JSONB per campi nuovi o rari che cambiano ancora spesso.
Una timeline a basso impatto che funziona bene:
- Settimana 1: Scegli 5–10 campi che devono essere filtrabili e riportabili. Aggiungi le colonne.
- Settimana 2: Backfilla quelle colonne dai JSONB esistenti per i record recenti prima, poi per quelli più vecchi.
- Settimana 3: Aggiorna le scritture in modo che i nuovi record popolino sia colonne che JSONB (scrittura doppia temporanea).
- Settimana 4: Passa letture e report alle colonne. Mantieni JSONB solo per gli extra.
Prossimi passi: decidi, documenta e continua a consegnare
Se non fai nulla, la decisione verrà presa per te. Il prototipo cresce, i bordi si induriscono e ogni cambiamento inizia a sembrare rischioso. Una mossa migliore è prendere una piccola decisione scritta ora e poi continuare a costruire.
Elenca le 5–10 domande che la tua app deve rispondere rapidamente ('Mostra tutti gli ordini aperti per questo cliente', 'Trova utenti per email', 'Report fatturato per mese'). Accanto a ciascuna, scrivi i vincoli che non puoi rompere (email unica, status obbligatorio, totali validi). Poi traccia un confine chiaro: tieni JSONB per campi che cambiano spesso e sono raramente filtrati o joinati, e promuovi a colonne e tabelle tutto ciò che cerchi, ordini, unisci o che devi validare sempre.
Se usi una piattaforma no-code che genera applicazioni reali, questa separazione può essere più facile da gestire nel tempo. Per esempio, AppMaster (appmaster.io) ti permette di modellare visivamente le tabelle PostgreSQL e rigenerare backend e app quando i requisiti cambiano, rendendo le modifiche iterative di schema e le migrazioni pianificate meno dolorose.
FAQ
Usa JSONB quando la forma dei dati cambia spesso e il flusso principale è memorizzare e recuperare il payload così com'è, come form in rapido cambiamento, webhook di partner, feature flag o impostazioni per cliente. Mantieni però un piccolo insieme di campi stabili come colonne normali in modo da poter filtrare e riportare in modo affidabile.
Normalizza quando i dati sono condivisi, interrogati in molti modi o devono essere attendibili per impostazione predefinita. Se servono campi obbligatori, valori unici, chiavi esterne o dashboard ed esportazioni coerenti, le tabelle con colonne chiare e vincoli risparmiano tempo nel medio termine.
Sì: spesso l'approccio migliore è ibrido: metti i campi critici per il business in colonne e relazioni, e conserva attributi opzionali o in rapido cambiamento in una colonna JSONB 'meta'. Così i report e le regole restano stabili, ma puoi comunque iterare sugli attributi long-tail.
Chiediti cosa gli utenti devono filtrare, ordinare ed esportare nell'interfaccia, e cosa deve essere corretto sempre (soldi, stato, proprietà, permessi, date). Se un campo è usato spesso in liste, dashboard o join, promuovilo a colonna reale; lascia in JSONB le rarità.
I rischi principali sono nomi di chiavi incoerenti, tipi di valore misti e cambiamenti silenziosi nel tempo che rompono l'analisi. Previeni questo usando chiavi coerenti, mantenendo il JSONB piccolo, memorizzando numeri/date come tipi appropriati (o come numeri JSON) e aggiungendo un piccolo campo di versione dentro il JSON.
Può esserlo, ma serve lavoro extra. JSONB non impone struttura di default, quindi avrai bisogno di controlli espliciti, indicizzazione attenta dei percorsi che interroghi e convenzioni forti. Gli schemi normalizzati rendono solitamente queste garanzie più semplici e visibili.
Indicizza solo quello che effettivamente interroghi. Usa indici btree normali per colonne comuni come status e timestamp; per JSONB preferisci indici di espressione sui campi 'caldi' (ad esempio estraendo un singolo campo) invece di indicizzare l'intero documento a meno che non cerchi davvero tra molte chiavi.
Segnali che è ora di migrare includono query lente e disordinate, scansioni complete frequenti e un insieme crescente di script ad-hoc solo per rispondere a domande semplici. Altri segnali sono più team che scrivono le stesse chiavi JSON in modo diverso e una crescente necessità di vincoli stabili o esportazioni coerenti.
Progetta prima le tabelle di destinazione, poi esegui le nuove strutture in parallelo con i dati JSONB. Backfilla a blocchi, convalida gli output, cambia le letture verso le nuove tabelle, poi passa le scritture e infine blocca il flusso con vincoli per evitare che dati errati ritornino.
Modella le entità core (customer, order, ticket) come tabelle con colonne chiare per i campi che le persone filtrano e riportano, poi aggiungi una colonna JSONB per gli extra flessibili. Strumenti come AppMaster (appmaster.io) possono aiutare perché permettono di aggiornare il modello PostgreSQL visivamente e rigenerare backend e app man mano che i requisiti cambiano.


