PostgreSQL per Web Developer: 10 Feature che Non Conosci

PostgreSQL per Web Developer: 10 Feature che Non Conosci

Perché PostgreSQL nel 2026

PostgreSQL non è più “solo” un database relazionale. Nel 2026 è diventato il cuore di stack come Supabase, che lo usa come base per auth, realtime e storage, e viene interrogato da ORM moderni come Drizzle ORM attraverso query completamente type-safe. Eppure la maggior parte dei developer lo usa come un MySQL rinominato: SELECT * FROM tabella WHERE id = $1 e stop.

Il problema è che PostgreSQL nasconde funzionalità straordinarie che nessun corso base ti insegna. Feature che potrebbero eliminare migliaia di righe di codice applicativo, sostituire servizi esterni da 50€/mese, e far girare query 10x più veloci. In questo articolo vedremo 10 feature che i web developer ignorano — con esempi reali e codice SQL pronto all’uso.

Non è una lista accademica. Ogni feature ha un caso d’uso concreto: un problema che probabilmente hai già incontrato e che hai risolto nel modo sbagliato. Che tu stia costruendo un SaaS, un’API REST o una dashboard interna, almeno cinque di queste feature cambieranno il tuo approccio al database.

1. JSONB: il Database NoSQL che hai già in produzione

JSONB (JSON Binary) trasforma PostgreSQL in un database document-oriented. A differenza del tipo JSON normale, JSONB indicizza i dati e li rende interrogabili con operatori specifici. Puoi memorizzare configurazioni utente, metadati flessibili, o payload di webhook senza creare colonne separate per ogni campo.

-- Crea tabella con colonna JSONB
CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  metadata JSONB DEFAULT '{}'
);

-- Inserisci dati JSON strutturati
INSERT INTO products (name, metadata) VALUES (
  'Laptop Pro',
  '{"brand": "Dell", "specs": {"ram": 16, "storage": 512}, "tags": ["work", "gaming"]}'
);

-- Interroga con operatori JSONB
SELECT name, metadata->>'brand' AS brand
FROM products
WHERE metadata @> '{"specs": {"ram": 16}}';

-- Crea indice GIN per ricerche veloci
CREATE INDEX idx_products_metadata ON products USING GIN (metadata);

-- Aggiorna un campo annidato senza sovrascrivere tutto
UPDATE products
SET metadata = jsonb_set(metadata, '{specs, ram}', '32')
WHERE id = 1;

L’operatore @> controlla se il JSON a sinistra contiene il JSON a destra. Con l’indice GIN questa query è O(log n) anche su milioni di righe. Puoi sostituire Redis per configurazioni semplici e MongoDB per schemi flessibili, tutto dentro PostgreSQL.

2. CTE e WITH ricorsivo: query senza loop nel codice

Le Common Table Expressions (CTE) con WITH ti permettono di scrivere query modulari e leggibili. Ma la vera magia è la variante WITH RECURSIVE: permette di attraversare strutture ad albero (menu, categorie, commenti annidati) con una sola query SQL, eliminando i loop JavaScript che fanno N query al database.

-- Struttura ad albero: categorie con parent_id
CREATE TABLE categories (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  parent_id INTEGER REFERENCES categories(id)
);

-- CTE ricorsiva: recupera tutti i discendenti di una categoria
WITH RECURSIVE category_tree AS (
  -- Caso base: il nodo radice
  SELECT id, name, parent_id, 0 AS depth, name::TEXT AS path
  FROM categories
  WHERE id = 1  -- parte dalla categoria con id=1

  UNION ALL

  -- Caso ricorsivo: figli dei nodi trovati
  SELECT c.id, c.name, c.parent_id, ct.depth + 1, ct.path || ' > ' || c.name
  FROM categories c
  INNER JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT id, name, depth, path
FROM category_tree
ORDER BY path;

-- Output esempio:
-- 1 | Elettronica   | 0 | Elettronica
-- 3 | Smartphone    | 1 | Elettronica > Smartphone
-- 7 | iPhone        | 2 | Elettronica > Smartphone > iPhone

Con la CTE ricorsiva hai eliminato completamente il pattern “fai una query, poi per ogni risultato fai un’altra query”. Una sola round-trip al database, zero loop nel codice Node.js o Python. Fondamentale per menu multilivello, thread di commenti e organigrammi aziendali.

3. Window Functions: analytics senza GROUP BY limitante

Le Window Functions calcolano valori su un set di righe correlate alla riga corrente, senza collassare i risultati come fa GROUP BY. Sono la risposta SQL a domande come “qual è il ranking di questo utente?”, “qual è la media mobile delle ultime 7 settimane?” o “qual è il delta rispetto al mese scorso?”.

-- Dati: ordini per utente nel tempo
CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  user_id INTEGER,
  amount NUMERIC(10,2),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Ranking degli utenti per fatturato totale
SELECT
  user_id,
  SUM(amount) AS total,
  RANK() OVER (ORDER BY SUM(amount) DESC) AS rank
FROM orders
GROUP BY user_id;

-- Media mobile a 30 giorni senza GROUP BY
SELECT
  id,
  user_id,
  amount,
  created_at,
  AVG(amount) OVER (
    PARTITION BY user_id
    ORDER BY created_at
    ROWS BETWEEN 29 PRECEDING AND CURRENT ROW
  ) AS moving_avg_30d
FROM orders;

-- Numero progressivo per utente (row_number per paginazione)
SELECT
  id,
  user_id,
  amount,
  ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS order_num
FROM orders;

PARTITION BY è come un “GROUP BY locale”: calcola la window function separatamente per ogni gruppo. ROW_NUMBER(), RANK(), DENSE_RANK(), LAG(), LEAD(), FIRST_VALUE() — sono tutti disponibili. Puoi costruire dashboard analytics complesse con una sola query invece di aggregare più risultati in JavaScript.

4. Row-Level Security (RLS): autorizzazioni nel database

Row-Level Security è la feature che Supabase usa per garantire che ogni utente veda solo i propri dati. Invece di filtrare con WHERE user_id = currentUser.id in ogni query del backend — rischiando di dimenticarlo una volta — definisci la policy direttamente in PostgreSQL. Il database applica automaticamente il filtro per ogni operazione.

-- Abilita RLS sulla tabella
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- Policy: gli utenti vedono solo i propri post
CREATE POLICY "users_see_own_posts"
ON posts
FOR SELECT
USING (author_id = current_setting('app.current_user_id')::INTEGER);

-- Policy: gli utenti possono inserire solo post a proprio nome
CREATE POLICY "users_insert_own_posts"
ON posts
FOR INSERT
WITH CHECK (author_id = current_setting('app.current_user_id')::INTEGER);

-- Policy: gli admin vedono tutto
CREATE POLICY "admins_see_all"
ON posts
FOR ALL
USING (current_setting('app.user_role') = 'admin');

-- Nel codice backend: imposta il contesto prima di ogni query
-- (esempio con Node.js e pg)
await client.query("SET app.current_user_id = $1", [userId]);
await client.query("SET app.user_role = $1", [userRole]);
-- Da questo punto ogni SELECT/INSERT/UPDATE rispetta le policy

RLS è una difesa in profondità: anche se il tuo codice applicativo ha un bug e non filtra per user_id, il database blocca l’accesso. È particolarmente potente in combinazione con Supabase, che imposta automaticamente il JWT dell’utente autenticato nelle variabili di sessione. Se stai costruendo un’app multi-tenant, RLS può sostituire intere classi di middleware di autorizzazione.

5. Full-Text Search nativo: Algolia gratis nel tuo database

La ricerca full-text di PostgreSQL è sorprendentemente potente. Supporta stemming multilingua (anche italiano), ranking per rilevanza, highlighting dei risultati e indici specializzati. Per la maggior parte dei SaaS con meno di 10 milioni di documenti, puoi evitare completamente Elasticsearch o Algolia.

-- Aggiungi colonna tsvector per ricerche precompilate
ALTER TABLE articles ADD COLUMN search_vector TSVECTOR;

-- Popola il vettore con titolo (peso A) e contenuto (peso B)
UPDATE articles SET search_vector =
  setweight(to_tsvector('italian', coalesce(title, '')), 'A') ||
  setweight(to_tsvector('italian', coalesce(content, '')), 'B');

-- Indice GIN per ricerche veloci
CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);

-- Trigger: aggiorna automaticamente il vettore ad ogni modifica
CREATE OR REPLACE FUNCTION update_search_vector()
RETURNS TRIGGER AS $$
BEGIN
  NEW.search_vector =
    setweight(to_tsvector('italian', coalesce(NEW.title, '')), 'A') ||
    setweight(to_tsvector('italian', coalesce(NEW.content, '')), 'B');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER articles_search_update
BEFORE INSERT OR UPDATE ON articles
FOR EACH ROW EXECUTE FUNCTION update_search_vector();

-- Ricerca con ranking per rilevanza
SELECT
  id, title,
  ts_rank(search_vector, query) AS rank,
  ts_headline('italian', content, query, 'MaxWords=20, MinWords=5') AS snippet
FROM articles, to_tsquery('italian', 'postgresql & developer') query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 10;

Il dizionario 'italian' gestisce automaticamente la stemming: cercare “sviluppatori” troverà anche “sviluppatore” e “sviluppo”. ts_headline() genera snippet con i termini evidenziati, come farebbe Algolia. Il tutto senza un servizio esterno da configurare e pagare ogni mese.

6. Generated Columns, Partial Index e altre ottimizzazioni

Tre feature spesso ignorate che possono ridurre drasticamente i tempi di query in produzione:

Generated Columns calcolano automaticamente un valore derivato da altre colonne. Utile per normalizzare dati (slug da titolo, prezzo con IVA, nome completo da nome+cognome) senza logica nel codice applicativo:

-- Generated column: slug calcolato automaticamente dal titolo
CREATE TABLE articles (
  id SERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  -- GENERATED ALWAYS AS: calcolato automaticamente, non scrivibile
  slug TEXT GENERATED ALWAYS AS (
    lower(regexp_replace(title, '[^a-zA-Z0-9]+', '-', 'g'))
  ) STORED,
  price NUMERIC(10,2),
  vat_rate NUMERIC(4,2) DEFAULT 0.22,
  price_with_vat NUMERIC(10,2) GENERATED ALWAYS AS (price * (1 + vat_rate)) STORED
);

-- Partial Index: indicizza solo le righe che soddisfano una condizione
-- Es: indice solo sugli utenti attivi (non tutti i 10M di utenti)
CREATE INDEX idx_active_users ON users (email)
WHERE is_active = TRUE AND deleted_at IS NULL;

-- Covering Index: include colonne extra nell'indice per evitare heap fetch
-- La query legge tutto dall'indice senza toccare la tabella
CREATE INDEX idx_orders_user_covering ON orders (user_id)
INCLUDE (amount, created_at, status);

-- Query che usa il covering index senza toccare la tabella principale
EXPLAIN SELECT amount, created_at, status
FROM orders
WHERE user_id = 42
-- "Index Only Scan" nel piano: nessun heap access

I Partial Index sono particolarmente utili in tabelle con soft-delete: un indice su WHERE deleted_at IS NULL è ordini di grandezza più piccolo dell’indice su tutta la tabella. Se hai una tabella con milioni di record di cui il 99% è archiviato, il partial index sarà piccolo e velocissimo.

Per chi usa Drizzle ORM e vuole sfruttare al meglio queste feature in TypeScript, l’articolo su Drizzle ORM nel 2026 mostra come definire indici e colonne generate con la type-safety di TypeScript.

7. LISTEN/NOTIFY: eventi real-time senza polling

LISTEN/NOTIFY è il sistema di messaggistica pub/sub integrato in PostgreSQL. Il database può inviare notifiche ai client connessi quando avvengono modifiche — zero polling, zero Redis Pub/Sub per use case semplici. È ciò che Supabase usa internamente per la funzionalità Realtime.

// Backend Node.js con pg: ascolta le notifiche
import pg from 'pg';

const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();

// Sottoscrivi al canale 'new_order'
await client.query('LISTEN new_order');

client.on('notification', (msg) => {
  const payload = JSON.parse(msg.payload);
  console.log('Nuovo ordine ricevuto:', payload);
  // Qui puoi: inviare una push notification, aggiornare una dashboard, ecc.
});

// Trigger PostgreSQL: invia notifica ad ogni nuovo ordine
// (SQL da eseguire una volta nel DB)
/*
CREATE OR REPLACE FUNCTION notify_new_order()
RETURNS TRIGGER AS $$
BEGIN
  PERFORM pg_notify(
    'new_order',
    json_build_object(
      'id', NEW.id,
      'user_id', NEW.user_id,
      'amount', NEW.amount
    )::TEXT
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER on_new_order
AFTER INSERT ON orders
FOR EACH ROW EXECUTE FUNCTION notify_new_order();
*/

Il payload delle notifiche è limitato a 8KB, quindi va bene per metadati (id, tipo evento) non per l’intero record. Se hai bisogno di real-time più complesso con auth integrata, vale la pena guardare Supabase Realtime che costruisce sopra LISTEN/NOTIFY un layer WebSocket pronto all’uso.

8. UPSERT, RETURNING e Batch Operations efficienti

Tre pattern SQL che eliminano round-trip inutili e codice boilerplate nel backend:

INSERT … ON CONFLICT (UPSERT): inserisci o aggiorna in una sola operazione atomica. Elimina il pattern “fai SELECT, se esiste UPDATE altrimenti INSERT” che ha race condition in ambienti concorrenti:

-- UPSERT: inserisci o aggiorna se esiste già
INSERT INTO user_settings (user_id, key, value, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id, key)
DO UPDATE SET
  value = EXCLUDED.value,
  updated_at = NOW()
RETURNING id, user_id, key, value;

-- RETURNING: ottieni i dati della riga appena modificata
-- Elimina la SELECT successiva per recuperare l'ID generato
INSERT INTO posts (title, content, author_id)
VALUES ($1, $2, $3)
RETURNING id, created_at, slug;

-- Batch INSERT efficiente con UNNEST (molto più veloce di N INSERT separati)
INSERT INTO events (user_id, event_type, metadata, created_at)
SELECT
  unnest($1::INTEGER[]) AS user_id,
  unnest($2::TEXT[]) AS event_type,
  unnest($3::JSONB[]) AS metadata,
  NOW()
-- Da Node.js: passa arrays come parametri
-- await db.query(sql, [[1,2,3], ['click','view','click'], ['{...}','{...}','{...}']]);

-- UPDATE con CTE: aggiorna e recupera i vecchi valori
WITH updated AS (
  UPDATE orders
  SET status = 'shipped', shipped_at = NOW()
  WHERE id = ANY($1::INTEGER[])
  RETURNING id, status, shipped_at, user_id
)
SELECT u.id, u.user_id, u.shipped_at
FROM updated u;

Il pattern con UNNEST per batch insert è fondamentale per performance: inviare 1000 righe in una query è 50-100x più veloce di 1000 INSERT separati. Se stai processando eventi analytics o importando dati CSV, questo è il modo corretto di farlo. La stessa tecnica la puoi usare per ottimizzare il pipeline di CI/CD che salva i risultati di audit nel database.

9. Materialized Views e pg_cron: cache e job scheduler integrati

Le Materialized Views sono snapshot precomputati di query complesse. Invece di ricalcolare statistiche aggregate ogni volta, le calcoli una volta e le aggiorni periodicamente. Ideale per dashboard e report che non devono essere real-time al millisecondo.

-- Crea una materialized view con statistiche aggregate
CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT
  DATE_TRUNC('month', created_at) AS month,
  COUNT(*) AS order_count,
  SUM(amount) AS total_revenue,
  AVG(amount) AS avg_order_value,
  COUNT(DISTINCT user_id) AS unique_customers
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month DESC
WITH DATA;

-- Indice sulla materialized view per query veloci
CREATE INDEX idx_monthly_revenue_month ON monthly_revenue (month);

-- Aggiorna i dati (puoi schedularlo con pg_cron)
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue;
-- CONCURRENTLY: aggiorna senza bloccare le letture (richiede unique index)

-- pg_cron: job scheduler direttamente in PostgreSQL
-- (richiede l'estensione pg_cron, disponibile su Supabase e AWS RDS)
SELECT cron.schedule(
  'refresh-monthly-revenue',   -- nome del job
  '0 1 * * *',                  -- ogni giorno all'1:00
  'REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue'
);

-- Elimina dati vecchi automaticamente (data retention)
SELECT cron.schedule(
  'cleanup-old-events',
  '0 3 * * 0',  -- ogni domenica alle 3:00
  'DELETE FROM analytics_events WHERE created_at < NOW() - INTERVAL ''90 days'''
);

Con pg_cron hai un job scheduler affidabile dentro PostgreSQL: niente Celery, niente cron di sistema, niente Lambda per task periodici semplici. È disponibile nativamente su Supabase e AWS RDS, ed è perfetto per refresh di statistiche, pulizia di dati obsoleti e invio di email programmate.

10. EXPLAIN ANALYZE e Query Optimization: debug delle query lente

L’ultima feature non è una keyword SQL ma un’abilità: leggere e interpretare EXPLAIN ANALYZE. Ogni developer che usa PostgreSQL in produzione deve saper diagnosticare query lente. Ecco i pattern di bottleneck più comuni e come risolverli:

-- Analizza una query lenta con timing e buffers
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.id, u.email, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > NOW() - INTERVAL '30 days'
GROUP BY u.id, u.email
ORDER BY order_count DESC
LIMIT 20;

-- Pattern da cercare nell'output EXPLAIN:
-- ❌ "Seq Scan" su tabelle grandi  → manca un indice
-- ❌ "Hash Join" con rows=XXXX estimated vs actual grandi  → stats obsolete
-- ❌ "Sort" prima di "Limit"  → considera indice sull'ORDER BY
-- ✅ "Index Scan" o "Index Only Scan"  → usa l'indice correttamente
-- ✅ "Bitmap Index Scan"  → efficiente per range queries

-- Aggiorna le statistiche del query planner
ANALYZE users;
ANALYZE orders;

-- Controlla gli indici non usati (candidati alla rimozione)
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND schemaname = 'public'
ORDER BY tablename;

-- Trova le query più lente (richiede pg_stat_statements)
SELECT query, mean_exec_time, calls, total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;

Il principio è semplice: se vedi Seq Scan su una tabella grande, ti manca un indice. Se le stime di righe del planner sono molto diverse dai valori reali, le statistiche sono obsolete (esegui ANALYZE). pg_stat_statements registra automaticamente tutte le query e i loro tempi medi: è la prima cosa da attivare in produzione.

FAQ e Domande Frequenti

PostgreSQL JSONB è più lento di MongoDB per dati JSON?

In benchmark reali su dataset tipici di SaaS (da 100K a 10M documenti), PostgreSQL con JSONB e indici GIN è comparabile o più veloce di MongoDB per letture indicizzate. MongoDB mantiene un vantaggio nelle scritture ad altissimo volume su hardware dedicato, ma per la maggior parte delle applicazioni web la differenza è irrilevante. Il vero vantaggio di JSONB è la possibilità di combinare dati relazionali e document in un’unica query JOIN, cosa impossibile con un database separato.

Row-Level Security rallenta le query?

L’overhead di RLS è minimo se le policy usano colonne indicizzate. La policy USING (user_id = current_setting('app.current_user_id')::INTEGER) su una tabella con indice su user_id ha un costo praticamente nullo — il filtro viene aggiunto alla query prima che il planner scelga il piano di esecuzione, quindi usa l’indice normalmente. Il rischio principale è dimenticare di impostare la variabile di sessione prima della query: PostgreSQL in quel caso ritorna un errore invece di mostrare dati sbagliati, il che è un comportamento sicuro.

Quando usare Materialized Views invece di una tabella normale?

Usa una Materialized View quando hai una query complessa (con JOIN, aggregazioni, window functions) che viene eseguita frequentemente ma i cui risultati possono essere leggermente in ritardo rispetto ai dati live. Il punto di forza è che la logica di aggregazione rimane in SQL — non in codice applicativo — e può essere aggiornata senza deploy. Se hai bisogno di dati sempre aggiornati al secondo, una tabella denormalizzata aggiornata da trigger è una soluzione alternativa, ma con maggiore complessità di manutenzione.

Come integro queste feature con un ORM come Drizzle o Prisma?

Gli ORM moderni supportano query raw SQL per le feature avanzate. Con Drizzle puoi usare sql\`...\` template literal per CTE, window functions e JSONB operator. Con Prisma usi prisma.$queryRaw. L’approccio consigliato è usare l’ORM per le operazioni CRUD standard e SQL raw per le query complesse — non devi scegliere uno o l’altro. Per tutti i dettagli su Drizzle, l’articolo su Drizzle ORM nel 2026 copre anche l’integrazione con query SQL custom.

Conclusione

PostgreSQL è uno dei software più potenti e sottoutilizzati dello stack moderno. Le 10 feature che abbiamo visto — JSONB, CTE ricorsive, window functions, RLS, full-text search, generated columns, LISTEN/NOTIFY, UPSERT/RETURNING, materialized views e EXPLAIN ANALYZE — non sono curiosità accademiche. Sono strumenti che ti permettono di spostare logica complessa dal codice applicativo al database, dove viene eseguita più vicino ai dati, con meno rete, meno codice e più robustezza.

Il passo successivo è scegliere una feature e applicarla a un progetto reale. Se usi Supabase, RLS e LISTEN/NOTIFY sono già configurati: è il momento di smettere di filtrare i dati nel backend e lasciare che il database faccia il lavoro. Se vuoi scalare le tariffe da freelance mostrando expertise PostgreSQL ai clienti, leggi anche come alzare le tariffe da freelance developer: conoscere il database a questo livello è esattamente il tipo di competenza che giustifica prezzi più alti.

Suggerimenti e Risorse

🔧 Strumento: Usa explain.dalibo.com per visualizzare graficamente i piani EXPLAIN ANALYZE di PostgreSQL. Incolla l’output JSON e ottieni un grafo interattivo dei nodi di esecuzione.

💡 Pro tip: Abilita pg_stat_statements in produzione dal primo giorno con CREATE EXTENSION pg_stat_statements;. Raccogliere dati sulle query lente fin dall’inizio ti salverà da debug difficili quando l’app scala.

🎯 Next step: Se vuoi un backend PostgreSQL completo con auth, RLS e API auto-generate, dai un’occhiata a Supabase nel 2026: implementa tutte queste feature con una configurazione minimale.

Condividi

Articoli Recenti

Categorie popolari