Salta al contenuto principale

PostgreSQL JSONB: Schema flessibile, indicizzazione e quando usarlo

·9 minuti
Indice dei contenuti
JSONB non è un sostituto degli schemi normalizzati. È uno strumento per dati genuinamente semi-strutturati dove la forma varia per riga e si ha bisogno di interrogare il contenuto. Usarlo con precisione evita commit prematuri allo schema. Usarlo ovunque significa aver reinventato un document store sopra un database relazionale.

Il tipo JSONB di PostgreSQL è una delle funzionalità più potenti e più abusate del database. Questo articolo copre i dettagli pratici: differenze di storage, operatori, indici GIN, funzioni di mutazione e i pattern dove JSONB guadagna il suo posto.

json vs jsonb: Usa sempre jsonb
#

PostgreSQL ha due tipi JSON: json e jsonb. La differenza conta in produzione.

jsonjsonb
StorageMemorizza il testo grezzo, preserva whitespace e ordine delle chiaviMemorizza una rappresentazione binaria decompostta
Prestazioni in letturaDeve re-analizzare ad ogni letturaFormato binario, nessuna re-analisi necessaria
Prestazioni in scritturaLeggermente più veloce (nessuna decomposizione)Leggermente più lento (decompone al momento della scrittura)
IndicizzazioneNessun supporto per indiciIndici GIN e btree supportati
Deduplicazione delle chiaviMantiene i duplicatiUltima scrittura vince sulle chiavi duplicate
Avviso

Non usare mai json in un nuovo schema. L’unica ragione per cui json esiste è per i casi in cui si ha bisogno di preservare l’ordine esatto delle chiavi o le chiavi duplicate (rari casi d’uso di auditing). Per tutto il resto, jsonb è strettamente migliore perché può essere indicizzato e interrogato in modo efficiente.

Interrogazione: ->, ->>, #>, #>>
#

I quattro operatori di navigazione:

  • -> restituisce un sotto-oggetto JSON o un elemento di array (tipo: jsonb)
  • ->> restituisce il valore come testo (tipo: text)
  • #> naviga un array di path, restituisce jsonb
  • #>> naviga un array di path, restituisce text
operators.sql
CREATE TABLE events (
    id      bigserial PRIMARY KEY,
    payload jsonb NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);

INSERT INTO events (payload) VALUES
  ('{"type": "order.created", "user": {"id": 42, "email": "alice@example.com"}, "items": [{"sku": "A1", "qty": 2}]}'),
  ('{"type": "order.shipped", "user": {"id": 42, "email": "alice@example.com"}, "tracking": "1Z999"}');

-- Ottieni il tipo di evento come jsonb (stringa con virgolette nell'output)
SELECT payload -> 'type' FROM events;

-- Ottieni il tipo di evento come testo semplice
SELECT payload ->> 'type' FROM events;

-- Naviga il path annidato: user.email
SELECT payload #>> '{user, email}' FROM events;

-- Filtra per valore annidato
SELECT * FROM events
WHERE payload ->> 'type' = 'order.created'
  AND (payload #>> '{user, id}')::int = 42;

Operatori di contenimento: @> e <@
#

Gli operatori di contenimento sono gli strumenti di query JSONB più potenti e funzionano con gli indici GIN.

A @> B significa “A contiene B come sottoinsieme di chiavi/valori?” A <@ B significa “A è contenuto in B?”

containment.sql
-- Trova tutti gli eventi dove lo user id è 42
SELECT * FROM events
WHERE payload @> '{"user": {"id": 42}}';

-- Trova eventi di tipi specifici
SELECT * FROM events
WHERE payload @> '{"type": "order.created"}';

-- Trova eventi che hanno un campo tracking
SELECT * FROM events
WHERE payload ? 'tracking';

-- Trova eventi dove il tipo è uno di un insieme
SELECT * FROM events
WHERE payload ->> 'type' = ANY(ARRAY['order.created', 'order.shipped']);

Indici GIN: cosa accelerano
#

Un indice GIN (Generalized Inverted Index) su una colonna JSONB indicizza tutte le chiavi e i valori all’interno del documento, abilitando query veloci di contenimento ed esistenza.

gin-index.sql
-- GIN predefinito con jsonb_ops: supporta @>, ?, ?|, ?&
CREATE INDEX idx_events_payload_gin ON events USING gin (payload);

-- GIN con jsonb_path_ops: indice più piccolo, supporta solo @>
-- Meglio quando si hanno solo query di contenimento
CREATE INDEX idx_events_payload_path_ops ON events USING gin (payload jsonb_path_ops);

-- Indice B-tree su un valore estratto specifico (per uguaglianza + range su un campo)
CREATE INDEX idx_events_type ON events ((payload ->> 'type'));
CREATE INDEX idx_events_user_id ON events (((payload #>> '{user, id}')::int));
Suggerimento

Usa jsonb_path_ops quando tutte le query usano il contenimento @>. Produce un indice più piccolo e veloce perché indicizza solo i valori, non le chiavi. Usa il jsonb_ops predefinito quando si hanno anche bisogno degli operatori ? (esistenza della chiave), ?| o ?&.

L’indice B-tree su un’espressione estratta è la scelta giusta quando si ha un campo specifico interrogato frequentemente con condizioni di uguaglianza o range. È significativamente più veloce di GIN per lookup su un singolo campo.

Aggiornare JSONB: jsonb_set, ||, e rimozione delle chiavi
#

Aggiornare un campo JSONB richiede la costruzione di un nuovo valore. PostgreSQL fornisce diverse funzioni per questo scopo.

jsonb-mutations.sql
-- jsonb_set(target, path, new_value, create_missing)
-- Aggiorna user.email in loco
UPDATE events
SET payload = jsonb_set(payload, '{user, email}', '"bob@example.com"', false)
WHERE id = 1;

-- jsonb_insert(target, path, new_value, insert_after)
-- Inserisce un nuovo elemento nell'array items alla posizione 0
UPDATE events
SET payload = jsonb_insert(payload, '{items, 0}', '{"sku": "B2", "qty": 1}', false)
WHERE id = 1;

-- Merge / shallow merge con l'operatore ||
UPDATE events
SET payload = payload || '{"status": "processed", "processed_at": "2025-01-01"}'
WHERE id = 1;

-- Rimuovi una chiave con l'operatore -
UPDATE events
SET payload = payload - 'tracking'
WHERE id = 2;

-- Rimuovi un path annidato con l'operatore #-
UPDATE events
SET payload = payload #- '{user, email}'
WHERE id = 1;
Nota

L’operatore || esegue un merge superficiale, non un merge profondo. Se sia il lato sinistro che il lato destro hanno la stessa chiave di oggetto annidato, il lato destro sostituisce il sinistro interamente, anziché unirli ricorsivamente. Usa jsonb_set per aggiornamenti mirati annidati.

Unnesting: jsonb_array_elements e jsonb_each
#

unnesting.sql
-- Espandi un array JSONB in righe
SELECT
  e.id,
  item ->> 'sku'      AS sku,
  (item ->> 'qty')::int AS qty
FROM events e,
  jsonb_array_elements(e.payload -> 'items') AS item
WHERE e.payload @> '{"type": "order.created"}';

-- Espandi tutte le coppie chiave-valore di un oggetto JSONB in righe
SELECT key, value
FROM events,
  jsonb_each(payload)
WHERE id = 1;

Caso d’uso reale: Event sourcing con JSONB
#

L’event sourcing è uno degli usi migliori per JSONB. Il tipo di evento determina la forma del payload, quindi una singola tabella events con una colonna JSONB gestisce naturalmente schemi di eventi eterogenei.

event-sourcing.sql
CREATE TABLE domain_events (
    id          bigserial PRIMARY KEY,
    aggregate   text        NOT NULL,
    agg_id      bigint      NOT NULL,
    event_type  text        NOT NULL,          -- estratto per indicizzazione
    version     int         NOT NULL,
    payload     jsonb       NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now()
);

-- Indice parziale: trova tutti gli eventi di spedizione a basso costo
CREATE INDEX idx_domain_events_shipment
  ON domain_events (agg_id, version)
  WHERE event_type = 'order.shipped';

-- GIN per query sugli attributi nel payload
CREATE INDEX idx_domain_events_payload
  ON domain_events USING gin (payload jsonb_path_ops);

-- Riproduci tutti gli eventi per un ordine specifico
SELECT event_type, version, payload
FROM domain_events
WHERE aggregate = 'order' AND agg_id = 42
ORDER BY version;

-- Trova tutti gli ordini spediti a una citta specifica
SELECT agg_id, payload
FROM domain_events
WHERE event_type = 'order.shipped'
  AND payload @> '{"address": {"city": "Milan"}}';

Integrazione con ORM
#

models.py
from sqlalchemy import Column, BigInteger, Text, DateTime
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase
import datetime

class Base(DeclarativeBase):
    pass

class DomainEvent(Base):
    __tablename__ = "domain_events"

    id         = Column(BigInteger, primary_key=True)
    aggregate  = Column(Text, nullable=False)
    agg_id     = Column(BigInteger, nullable=False)
    event_type = Column(Text, nullable=False)
    version    = Column(BigInteger, nullable=False)
    payload    = Column(JSONB, nullable=False)
    created_at = Column(DateTime(timezone=True), default=datetime.datetime.utcnow)

# Query usando il contenimento (usa l'indice GIN)
session.query(DomainEvent).filter(
    DomainEvent.event_type == "order.shipped",
    DomainEvent.payload.contains({"address": {"city": "Milan"}})
).all()
models.py
from django.db import models

class DomainEvent(models.Model):
    aggregate  = models.TextField()
    agg_id     = models.BigIntegerField()
    event_type = models.TextField(db_index=True)
    version    = models.IntegerField()
    payload    = models.JSONField()  # si mappa a jsonb in PostgreSQL
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        indexes = [
            models.Index(fields=["aggregate", "agg_id", "version"]),
        ]

# Query usando il lookup per chiave (tradotto nell'operatore ->>)
DomainEvent.objects.filter(
    event_type="order.shipped",
    payload__address__city="Milan"
)
models.go
package models

import (
    "database/sql/driver"
    "encoding/json"
    "fmt"
    "time"
)

type JSONB map[string]interface{}

func (j JSONB) Value() (driver.Value, error) {
    b, err := json.Marshal(j)
    return string(b), err
}

func (j *JSONB) Scan(value interface{}) error {
    bytes, ok := value.([]byte)
    if !ok {
        return fmt.Errorf("failed to unmarshal JSONB: %v", value)
    }
    return json.Unmarshal(bytes, j)
}

type DomainEvent struct {
    ID        uint      `gorm:"primarykey"`
    Aggregate string    `gorm:"not null;index:idx_agg"`
    AggID     int64     `gorm:"not null;index:idx_agg"`
    EventType string    `gorm:"not null;index"`
    Version   int       `gorm:"not null"`
    Payload   JSONB     `gorm:"type:jsonb;not null"`
    CreatedAt time.Time
}

// Query raw per il contenimento GIN
var results []DomainEvent
db.Raw(
    `SELECT * FROM domain_events WHERE event_type = ? AND payload @> ?::jsonb`,
    "order.shipped", `{"address": {"city": "Milan"}}`,
).Scan(&results)

Quando NON usare JSONB
#

JSONB è la scelta giusta quando la struttura varia genuinamente per riga. È la scelta sbagliata quando:

  • Il campo è dati relazionali (relazioni tra utenti, prodotti, ordini appartengono a tabelle normalizzate)
  • Il campo viene interrogato frequentemente per un attributo specifico (farlo diventare una colonna, aggiungere un indice B-tree)
  • Si ha bisogno di integrità referenziale (i valori JSONB non sono foreign key)
  • La struttura annidata è profonda e verrebbe meglio espressa come un join
Importante

Se ci si trova a estrarre costantemente lo stesso path (payload ->> 'status') nelle clausole WHERE e ORDER BY, quel campo dovrebbe essere una colonna. La flessibilità di JSONB ha un costo: statistiche del query planner ridotte, nessuna integrità referenziale e nessun type safety.

Errori comuni
#

Usare json invece di jsonb
Il tipo json memorizza testo grezzo e non può essere indicizzato. Ogni query che filtra o estrae da una colonna json re-analizza l’intero testo. In qualsiasi workload realistico questo è una regressione delle prestazioni rispetto a jsonb. Non c’è un motivo valido per usare json per le nuove tabelle.
Nessun indice GIN su una colonna JSONB interrogata
Senza un indice GIN, le query di contenimento (@>) e le query di esistenza delle chiavi (?) eseguono una scansione sequenziale completa. Su tabelle con milioni di righe, questa è la differenza tra millisecondi e minuti. Aggiungere un indice GIN su qualsiasi colonna JSONB su cui si filtra.
Aggiornare JSONB con concatenazione di stringhe
Costruire JSON nel codice applicativo e sovrascrivere l’intera colonna perde qualsiasi aggiornamento concorrente ed è soggetto a errori. Usare jsonb_set per aggiornamenti mirati e l’operatore || per i merge superficiali. Non concatenare mai stringhe per costruire JSON per le query SQL.
Memorizzare strutture profondamente annidate che dovrebbero essere colonne
Un oggetto JSONB a tre livelli di annidamento con cinque chiavi interrogate frequentemente è un problema di manutenzione. Il query planner non può raccogliere statistiche sui contenuti JSONB, quindi le stime sono approssimative e i piani di query possono essere subottimali. Estrarre i campi interrogati frequentemente come colonne e usare la colonna JSONB per il resto genuinamente variabile.

Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.