Els esquemes canvien. Sempre. Cal desar el telèfon dels clients, el VARCHAR(60) es queda curt, algú s'ha adonat que aquella columna estava mal anomenada, màrqueting vol una columna total a comandes per no recalcular-la a cada informe, i una regla de negoci que fins ara vivia al codi hauria de ser en un CHECK. La base de dades que vas crear a 05-01 i vas omplir a 05-02 no és un monument: és un organisme en producció que mutarà desenes de vegades.

ALTER TABLE és la instrucció que ho permet. La seva sintaxi s'aprèn en vint minuts. El que separa un professional d'un accident no és la sintaxi: és saber quines operacions bloquegen la taula i quines no, entendre que a PostgreSQL una migració es pot desfer amb ROLLBACK i a MySQL no, conèixer el patró que permet canviar un esquema sense aturar el servei, i acceptar la regla que regeix tota la indústria: cap canvi d'estructura s'escriu a mà en una consola de producció.

Aquesta lliçó cobreix les dues meitats, i tanca el mòdul.

⚠️ Avís de seguretat

ALTER TABLE és una operació destructiva i irreversible en els seus efectes sobre les dades. Un DROP COLUMN elimina una columna sencera i tot el seu contingut; un ALTER COLUMN TYPE pot truncar valors; un ADD CONSTRAINT pot rebutjar files existents.

  • Executa tots els exemples sobre la teva base de dades de pràctiques (botigaverda), mai en producció.
  • Fes una còpia de seguretat prèvia: pg_dump -U curs_sql -d botigaverda -f copia.sql.
  • Treballa dins de BEGINROLLBACK: a PostgreSQL el DDL és transaccional i ho pots desfer.
  • En un sistema real, tot canvi d'esquema ha d'anar revisat pel responsable de la base de dades, provat abans en un entorn equivalent al de producció i aplicat com a migració versionada, mai a mà.

Contingut

  1. ALTER TABLE: el mapa d'operacions
  2. ADD COLUMN, amb i sense DEFAULT
  3. DROP COLUMN
  4. RENAME COLUMN i RENAME TO
  5. ALTER COLUMN TYPE i la clàusula USING
  6. SET / DROP NOT NULL i SET / DROP DEFAULT
  7. ADD / DROP CONSTRAINT, i la tècnica NOT VALID + VALIDATE
  8. Què bloqueja la taula i què no
  9. DDL transaccional: PostgreSQL davant de MySQL
  10. El patró expand/contract
  11. Migracions versionades
  12. Què fer i què no fer en producció
  13. Errors habituals i consells
  14. Exercicis
  15. Conclusió del mòdul

  1. ALTER TABLE: el mapa d'operacions

ALTER TABLE [IF EXISTS] nom_taula acció [, acció ...];

Les accions més habituals:

Acció Què fa
ADD COLUMN col TIPUS [restriccions] Afegeix una columna
DROP COLUMN col [CASCADE] Elimina una columna i les seves dades
RENAME COLUMN vella TO nova Reanomena una columna
RENAME TO nova_taula Reanomena la taula
ALTER COLUMN col TYPE tipus_nou [USING expr] Canvia el tipus
ALTER COLUMN col SET NOT NULL / DROP NOT NULL Afegeix o treu obligatorietat
ALTER COLUMN col SET DEFAULT expr / DROP DEFAULT Afegeix o treu valor per omissió
ADD CONSTRAINT nom ... Afegeix CHECK, UNIQUE, PRIMARY KEY o FOREIGN KEY
DROP CONSTRAINT nom Elimina una restricció
VALIDATE CONSTRAINT nom Valida una restricció declarada NOT VALID

Un detall de sintaxi que estalvia molt de temps: diverses accions caben en una sola sentència, separades per comes. I això no és només elegància — és que la taula es bloqueja una vegada en lloc de N:

ALTER TABLE clients
    ADD COLUMN telefon    VARCHAR(20),
    ADD COLUMN newsletter BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE
botigaverda=> \d clients
                            Table "public.clients"
     Column      |          Type          | Nullable |           Default
-----------------+------------------------+----------+----------------------------------
 id              | integer                | not null | generated by default as identity
 nom             | character varying(60)  | not null |
 cognoms         | character varying(90)  | not null |
 email           | character varying(120) | not null |
 ciutat          | character varying(80)  |          |
 pais            | character varying(60)  | not null |
 data_registre   | date                   | not null | CURRENT_DATE
 referit_per_id  | integer                |          |
 telefon         | character varying(20)  |          |
 newsletter      | boolean                | not null | false

Avís sobre els objectes d'aquesta lliçó. clients.telefon, clients.newsletter i comandes.total són exemples puntuals: no formen part de l'esquema canònic de BotigaVerda i no apareixeran als mòduls següents. Recarrega botigaverda.sql en acabar.

  1. ADD COLUMN, amb i sense DEFAULT

(Recarrega botigaverda.sql abans de continuar: els exemples d'aquest apartat tornen a afegir les dues columnes de l'anterior, aquesta vegada una a una.)

Sense DEFAULT

ALTER TABLE clients ADD COLUMN telefon VARCHAR(20);
ALTER TABLE

La columna neix nul·lable i totes les files existents queden amb NULL. És instantani independentment de la mida de la taula: PostgreSQL només anota la columna nova al seu catàleg, sense tocar ni un sol byte de dades.

SELECT id, nom, cognoms, telefon FROM clients ORDER BY id LIMIT 3;
id nom cognoms telefon
1 Lucía Martínez Soler (null)
2 Carlos Ferrer Ibáñez (null)
3 Marta Sanchis Gil (null)

Amb DEFAULT

ALTER TABLE clients
ADD COLUMN newsletter BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE
SELECT COUNT(*) FILTER (WHERE NOT newsletter) AS sense_newsletter, COUNT(*) AS total
FROM clients;
sense_newsletter total
15 15

Les quinze files tenen FALSE. I aquí hi ha una de les millores més importants de PostgreSQL de l'última dècada:

Des de PostgreSQL 11, afegir una columna amb DEFAULT constant ja no reescriu la taula. Abans, aquesta operació recorria i reescrivia cada fila per gravar-hi el valor: en una taula de cent milions de files eren hores de bloqueig exclusiu — el cas d'estudi clàssic de "com tombar producció amb una línia d'SQL".

Ara PostgreSQL desa el valor per omissió al catàleg i el retorna "al vol" per a les files antigues, escrivint-lo físicament només quan aquelles files s'actualitzen per un altre motiu. L'operació passa d'hores a mil·lisegons.

L'excepció, que continua sent cara:

-- ⚠️ Això SÍ que reescriu la taula sencera: el DEFAULT és volàtil
ALTER TABLE clients
ADD COLUMN token UUID NOT NULL DEFAULT gen_random_uuid();

Si el DEFAULT no és constant —una funció aleatòria, un NOW() que ha de diferir per fila— cada fila necessita el seu propi valor i no hi ha cap drecera possible. La regla:

DEFAULT Reescriu la taula?
Cap No
Constant (0, FALSE, 'pendent') No (PostgreSQL 11+)
Funció estable avaluada una vegada (CURRENT_DATE) No: el valor es fixa en executar l'ALTER
Funció volàtil (gen_random_uuid(), random()) , fila a fila

Nota de dialecte: MySQL 8 amb InnoDB permet ADD COLUMN amb ALGORITHM=INSTANT en molts casos (columna al final de la taula, sense canvi de format de fila); si no, reconstrueix. Oracle té una optimització equivalent des d'11g. SQLite afegeix columnes al final de manera barata però no permet afegir-les amb un DEFAULT no constant. SQL Server distingeix entre afegir una columna nul·lable (instantani) i una NOT NULL amb DEFAULT (instantani des del 2012 Enterprise, reescriptura a la resta d'edicions).

  1. DROP COLUMN

ALTER TABLE clients DROP COLUMN newsletter;
ALTER TABLE

També és instantani: PostgreSQL no esborra les dades, marca la columna com a eliminada al catàleg i deixa de mostrar-la. L'espai es recupera quan cada fila es reescriu (per un UPDATE o per un VACUUM FULL).

Això té dues implicacions que convé conèixer:

  1. La dada continua físicament en disc fins que es reescrigui la fila. Si la columna contenia informació sensible, un DROP COLUMN no és un esborrat segur.
  2. L'operació és irreversible des d'SQL un cop confirmada. No hi ha UNDROP.

Si alguna cosa depèn de la columna (una restricció, una vista, un índex), DROP COLUMN falla:

ALTER TABLE productes DROP COLUMN preu;
ERROR:  cannot drop column preu of table productes because other objects depend on it
DETAIL:  constraint productes_preu_check on table productes depends on column preu of table productes
HINT:  Use DROP ... CASCADE to drop the dependent objects too.

Amb CASCADE s'endú per davant el que en depengui:

ALTER TABLE productes DROP COLUMN preu CASCADE;
NOTICE:  drop cascades to constraint productes_preu_check on table productes
ALTER TABLE

I amb això acabes de destruir la columna més important del catàleg i la seva restricció. CASCADE a ALTER TABLE mereix el mateix respecte que a DELETE.

  1. RENAME COLUMN i RENAME TO

ALTER TABLE ressenyes RENAME COLUMN comentari TO text;
ALTER TABLE ressenyes RENAME TO valoracions;
ALTER TABLE
ALTER TABLE

Totes dues són purament de catàleg: instantànies, sense tocar dades, i reversibles amb un altre RENAME.

I totes dues són, en un sistema amb aplicacions connectades, de les operacions més perilloses que existeixen. El motiu no és tècnic: és que en l'instant en què confirmes el canvi, tot el codi que esmenta el nom antic deixa de funcionar:

SELECT comentari FROM ressenyes;
ERROR:  relation "ressenyes" does not exist
LINE 1: SELECT comentari FROM ressenyes;
                              ^

Un ALTER TABLE de dos segons ha trencat l'aplicació sencera. I no hi ha finestra de transició: o el codi fa servir el nom vell, o fa servir el nou.

La regla: un RENAME en producció mai no es fa de cop. Es fa amb el patró expand/contract de l'apartat 10, o no es fa. I si el nom és lleig però funciona, moltes vegades la resposta correcta és deixar-lo lleig.

Desfem els dos canvis abans de continuar:

ALTER TABLE valoracions RENAME TO ressenyes;
ALTER TABLE ressenyes RENAME COLUMN text TO comentari;

  1. ALTER COLUMN TYPE i la clàusula USING

ALTER TABLE productes ALTER COLUMN preu TYPE NUMERIC(12,2);
ALTER TABLE

Ampliar la precisió d'un NUMERIC funciona directament, perquè tots els valors existents caben al tipus nou. El mateix passa en passar de VARCHAR(60) a VARCHAR(120) o a TEXT: PostgreSQL reconeix aquells casos com a compatibles a nivell binari i no reescriu res.

Reduir, en canvi, pot fallar:

ALTER TABLE clients ALTER COLUMN nom TYPE VARCHAR(5);
ERROR:  value too long for type character varying(5)

I això és una bona notícia: PostgreSQL comprova totes les files abans d'aplicar el canvi, i si una sola no hi cap, avorta sense tocar res. Mai no trunca en silenci.

Nota de dialecte: MySQL, segons el seu mode SQL, sí que pot truncar en silenci en reduir un VARCHAR. Amb sql_mode a STRICT_TRANS_TABLES (el valor per omissió des de MySQL 5.7) dóna error; amb el mode relaxat, retalla el text i emet un avís que gairebé ningú no llegeix. És una de les diferències de comportament més perilloses entre motors.

La clàusula USING

Quan la conversió no és automàtica, PostgreSQL t'ho diu i t'ofereix la sortida:

-- Exemple puntual: taula auxiliar amb dades "brutes" d'una importació
CREATE TABLE comandes_importades (
    id          INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    referencia  VARCHAR(20) NOT NULL,
    data_text   VARCHAR(10) NOT NULL,
    import_text VARCHAR(15) NOT NULL
);

INSERT INTO comandes_importades (referencia, data_text, import_text) VALUES
('BV-2026-001', '2026-03-01', '47.05'),
('BV-2026-002', '2026-03-02', '26.70'),
('BV-2026-003', '2026-03-03', '34.48');
CREATE TABLE
INSERT 0 3

Un intent directe de convertir el text a data:

ALTER TABLE comandes_importades ALTER COLUMN data_text TYPE DATE;
ERROR:  column "data_text" cannot be cast automatically to type date
HINT:  You might need to specify "USING data_text::date".

USING rep una expressió que calcula el valor nou a partir de l'antic, fila a fila:

ALTER TABLE comandes_importades
    ALTER COLUMN data_text   TYPE DATE          USING data_text::DATE,
    ALTER COLUMN import_text TYPE NUMERIC(10,2) USING import_text::NUMERIC(10,2);
ALTER TABLE
SELECT id, referencia, data_text, import_text FROM comandes_importades ORDER BY id;
id referencia data_text import_text
1 BV-2026-001 2026-03-01 47.05
2 BV-2026-002 2026-03-02 26.70
3 BV-2026-003 2026-03-03 34.48

USING admet qualsevol expressió, no només conversions. Per exemple, per passar linies_comanda.descompte de fracció (0.10) a percentatge enter (10):

-- Exemple hipotètic: NO ho apliquis a BotigaVerda, trencaries
-- tots els càlculs dels mòduls 2 a 4
ALTER TABLE linies_comanda
ALTER COLUMN descompte TYPE SMALLINT USING (descompte * 100)::SMALLINT;

Tres avisos sobre ALTER COLUMN TYPE:

Avís Detall
Reescriu la taula sencera llevat dels casos binari-compatibles Amb bloqueig exclusiu durant tot el procés
Reconstrueix els índexs que la incloguin Cost addicional proporcional
Pot invalidar restriccions i vistes dependents PostgreSQL les recrea si pot, i si no, falla

  1. SET / DROP NOT NULL i SET / DROP DEFAULT

-- Fer obligatòria una columna que no ho era
ALTER TABLE clients ALTER COLUMN ciutat SET NOT NULL;
ERROR:  column "ciutat" of relation "clients" contains null values

Falla si hi ha files amb NULL… llevat que a BotigaVerda tots els clients tinguin ciutat, que és el cas. Provem-ho amb una que sí que té nuls:

ALTER TABLE comandes ALTER COLUMN empleat_id SET NOT NULL;
ERROR:  column "empleat_id" of relation "comandes" contains null values

Les deu comandes web ho impedeixen, i correctament: aquell NULL significa alguna cosa (04-03).

L'operació inversa sempre funciona, perquè relaxar una restricció no pot invalidar mai dades existents:

ALTER TABLE productes ALTER COLUMN preu DROP NOT NULL;
ALTER TABLE
ALTER TABLE productes ALTER COLUMN preu SET NOT NULL;   -- ho deixem com estava
ALTER TABLE

Els valors per omissió funcionen igual, i són pur catàleg:

ALTER TABLE productes ALTER COLUMN stock SET DEFAULT 10;
ALTER TABLE productes ALTER COLUMN stock SET DEFAULT 0;    -- ho deixem com estava
ALTER TABLE clients   ALTER COLUMN data_registre DROP DEFAULT;
ALTER TABLE clients   ALTER COLUMN data_registre SET DEFAULT CURRENT_DATE;
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE

Canviar el DEFAULT no afecta les files existents. Només canvia el que s'omplirà a les insercions futures. És un error clàssic esperar el contrari.

  1. ADD / DROP CONSTRAINT, i la tècnica NOT VALID + VALIDATE

Aquí es cobra la insistència de 05-01 a anomenar les restriccions: per treure'n una cal anomenar-la, i no pots anomenar el que no saps com es diu.

ALTER TABLE productes
ADD CONSTRAINT chk_productes_marge CHECK (cost IS NULL OR cost <= preu);
ALTER TABLE

Funciona perquè cap dels 20 productes no té un cost superior al seu preu. Si n'hi hagués, PostgreSQL rebutjaria l'ALTER TABLE sencer:

ERROR:  check constraint "chk_productes_marge" of relation "productes" is violated by some row

Treure-la:

ALTER TABLE productes DROP CONSTRAINT chk_productes_marge;
ALTER TABLE

I les altres tres famílies:

ALTER TABLE ressenyes ADD CONSTRAINT uq_ressenyes_producte_client UNIQUE (producte_id, client_id);

-- Sobre la taula auxiliar d'importació de l'apartat 5
ALTER TABLE comandes_importades
ADD CONSTRAINT uq_comandes_importades_ref UNIQUE (referencia);

ALTER TABLE comandes_importades
ADD CONSTRAINT chk_comandes_importades_import CHECK (import_text >= 0);
ALTER TABLE
ALTER TABLE
ALTER TABLE

El problema: validar bloqueja

Afegir un CHECK o una FOREIGN KEY obliga PostgreSQL a comprovar totes les files existents, i ho fa amb un bloqueig exclusiu. En una taula de deu milions de files això pot ser un minut llarg durant el qual ningú no pot llegir ni escriure.

La solució en dos temps:

-- Fase 1: afegir sense validar. Instantani.
ALTER TABLE productes
ADD CONSTRAINT chk_productes_marge CHECK (cost IS NULL OR cost <= preu) NOT VALID;
ALTER TABLE
-- Fase 2: validar. Pot trigar, però NO bloqueja lectures ni escriptures.
ALTER TABLE productes VALIDATE CONSTRAINT chk_productes_marge;
ALTER TABLE

Què fa exactament NOT VALID:

Amb NOT VALID Després de VALIDATE CONSTRAINT
Files noves o modificades Es comproven des del primer instant Es comproven
Files existents No es comproven Es comproven una vegada
Bloqueig ACCESS EXCLUSIVE, però instantani SHARE UPDATE EXCLUSIVE: no bloqueja lectures ni escriptures
El planificador la pot aprofitar No

És a dir: NOT VALID et dóna la protecció cap al futur immediatament, i deixa la comprovació de l'històric per a un moment tranquil. És la tècnica estàndard per afegir restriccions a taules grans en producció.

I comprovar quines restriccions estan sense validar:

SELECT conname AS restriccio, convalidated AS validada
FROM   pg_constraint
WHERE  conrelid = 'productes'::regclass
ORDER BY conname;
restriccio validada
chk_productes_marge true
productes_categoria_id_fkey true
productes_cost_check true
productes_pkey true
productes_preu_check true
productes_proveidor_id_fkey true
productes_stock_check true

Desfem l'exemple:

ALTER TABLE productes DROP CONSTRAINT chk_productes_marge;

  1. Què bloqueja la taula i què no

Aquest és l'apartat que separa una migració innòcua d'una caiguda de producció.

PostgreSQL protegeix cada operació amb un nivell de bloqueig. El més agressiu és ACCESS EXCLUSIVE: mentre es manté, cap altra sessió no pot ni tan sols llegir la taula. Totes les consultes es queden esperant.

Operació Nivell de bloqueig Reescriu? Escaneja? Durada
ADD COLUMN sense DEFAULT ACCESS EXCLUSIVE No No Instantània
ADD COLUMN amb DEFAULT constant ACCESS EXCLUSIVE No (PG 11+) No Instantània
ADD COLUMN amb DEFAULT volàtil ACCESS EXCLUSIVE Proporcional a la mida
DROP COLUMN ACCESS EXCLUSIVE No No Instantània
RENAME COLUMN / RENAME TO ACCESS EXCLUSIVE No No Instantània
SET DEFAULT / DROP DEFAULT ACCESS EXCLUSIVE No No Instantània
DROP NOT NULL ACCESS EXCLUSIVE No No Instantània
SET NOT NULL ACCESS EXCLUSIVE No Proporcional
ALTER COLUMN TYPE (binari-compatible) ACCESS EXCLUSIVE No No Instantània
ALTER COLUMN TYPE (resta) ACCESS EXCLUSIVE Proporcional
ADD CONSTRAINT CHECK ACCESS EXCLUSIVE No Proporcional
ADD CONSTRAINT CHECK ... NOT VALID ACCESS EXCLUSIVE No No Instantània
ADD FOREIGN KEY ACCESS EXCLUSIVE a totes dues taules No Proporcional
ADD FOREIGN KEY ... NOT VALID SHARE ROW EXCLUSIVE No No Instantània
VALIDATE CONSTRAINT SHARE UPDATE EXCLUSIVE No No bloqueja lectures ni escriptures
ADD UNIQUE / ADD PRIMARY KEY ACCESS EXCLUSIVE No Sí (construeix índex) Proporcional
DROP CONSTRAINT ACCESS EXCLUSIVE No No Instantània
CREATE INDEX SHARE No Bloqueja escriptures
CREATE INDEX CONCURRENTLY SHARE UPDATE EXCLUSIVE No Sí (dues passades) No bloqueja escriptures (mòdul 8)

El detall que mata: la cua de bloqueigs

I ara el veritablement important, que gairebé ningú no explica:

Una operació "instantània" no és inofensiva si no aconsegueix el bloqueig.

Un ALTER TABLE ADD COLUMN triga un mil·lisegon… un cop ha obtingut l'ACCESS EXCLUSIVE. Si en aquell moment hi ha una consulta llarga llegint la taula, l'ALTER es posa a esperar. I mentre espera, PostgreSQL encua darrere seu totes les consultes noves, perquè els bloqueigs es concedeixen per ordre d'arribada.

sequenceDiagram
    participant R as Informe (5 min)
    participant A as ALTER TABLE
    participant N as 200 consultes noves
    R->>R: SELECT llarg · bloqueig ACCESS SHARE
    A->>A: demana ACCESS EXCLUSIVE → ESPERA
    N->>N: demanen ACCESS SHARE → esperen DARRERE de l'ALTER
    Note over R,N: 💥 La taula queda inaccessible 5 minuts<br/>per un ALTER d'1 ms

Un ALTER TABLE d'un mil·lisegon acaba de deixar la taula inaccessible cinc minuts. La protecció estàndard:

BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE clients ADD COLUMN telefon VARCHAR(20);
COMMIT;

Si en tres segons no aconsegueix el bloqueig, avorta amb canceling statement due to lock timeout en lloc de bloquejar la base. Es reintenta més tard. Posar lock_timeout a tota migració és una de les pràctiques més rendibles que existeixen.

  1. DDL transaccional: PostgreSQL davant de MySQL

Aquesta és una diferència crítica entre motors, i canvia completament com s'escriu una migració.

A PostgreSQL, el DDL és transaccional:

BEGIN;

ALTER TABLE clients ADD COLUMN telefon VARCHAR(20);
ALTER TABLE clients ADD COLUMN newsletter BOOLEAN NOT NULL DEFAULT FALSE;

SELECT id, nom, telefon, newsletter FROM clients ORDER BY id LIMIT 2;
id nom telefon newsletter
1 Lucía (null) false
2 Carlos (null) false
ROLLBACK;
ROLLBACK
SELECT id, nom, telefon FROM clients LIMIT 1;
ERROR:  column "telefon" does not exist
LINE 1: SELECT id, nom, telefon FROM clients LIMIT 1;
                       ^

Com si no hagués passat mai. Els dos ALTER TABLE s'han desfet.

La conseqüència pràctica és enorme: a PostgreSQL pots embolcallar una migració de quinze passos en BEGINCOMMIT i tenir la garantia que o s'aplica sencera o no s'aplica res. Si el pas 12 falla, l'esquema queda exactament com estava.

PostgreSQL MySQL / MariaDB SQL Server Oracle SQLite
DDL transaccional No No
ROLLBACK d'un ALTER TABLE Funciona Impossible Funciona Impossible Funciona
BEGIN abans d'un DDL Es respecta Confirma implícitament la transacció oberta Es respecta Confirma implícitament Es respecta
Migració a mitges possible No No No

A MySQL i Oracle, cada sentència DDL confirma implícitament la transacció en curs. Si una migració de quinze passos falla al dotze, els onze primers ja estan aplicats i no hi ha manera de desfer-los. Per això en aquells motors tota migració necessita el seu script de reversió escrit a mà, i per això les eines de l'apartat 11 insisteixen tant en el concepte de down migration.

Si treballes amb MySQL, interioritza això: no existeix la xarxa de seguretat. Cada pas de la migració ha de ser reversible per si sol, i l'ordre importa moltíssim més.

  1. El patró expand/contract

Com es canvia un esquema sense aturar el servei.

El problema de fons: la base de dades i l'aplicació es despleguen per separat, i durant una estona conviuen la versió antiga i la nova del codi. Qualsevol canvi que trenqui una de les dues provoca errors. Un RENAME COLUMN, com has vist, trenca la versió antiga en l'instant en què es confirma.

Expand/contract (també anomenat parallel change) resol això descomponent el canvi en cinc fases, cadascuna compatible cap endavant i cap enrere:

flowchart TD
    A["1 · EXPAND<br/>Afegir el nou<br/>sense tocar el vell"] --> B["2 · DOBLE ESCRIPTURA<br/>L'aplicació escriu<br/>als dos llocs"]
    B --> C["3 · BACKFILL<br/>Omplir el nou<br/>amb les dades històriques"]
    C --> D["4 · CANVIAR LA LECTURA<br/>L'aplicació llegeix del nou<br/>i es verifica que coincideix"]
    D --> E["5 · CONTRACT<br/>Deixar d'escriure al vell<br/>i eliminar-lo"]
    style A fill:#e8f5e9
    style E fill:#ffebee

La clau és que en cap moment no existeix un estat en què l'aplicació pugui fallar: entre la fase 1 i la 5, totes dues versions del codi funcionen.

El cas: afegir comandes.total

BotigaVerda calcula el total de cada comanda sumant-ne les línies i afegint-hi els ports. És correcte (01-05 ho justificava: res d'agregats precalculats), però els informes del mòdul 4 repeteixen aquella expressió una vegada i una altra, i amb volum alt el cost es nota. Direcció demana una columna total desnormalitzada.

Fase 1 — Expand: afegir la columna, nul·lable.

ALTER TABLE comandes ADD COLUMN total NUMERIC(10,2);
ALTER TABLE

Instantani, nul·lable, sense DEFAULT. El codi antic continua funcionant: no sap que la columna existeix i no la necessita.

Fase 2 — Doble escriptura. Es desplega una versió de l'aplicació que, a més de crear la comanda i les seves línies, omple total. Les comandes noves tenen valor; les antigues continuen a NULL. Cap canvi d'esquema en aquesta fase — és pur desplegament de codi.

Fase 3 — Backfill: omplir cap enrere.

Amb les eines d'aquest mòdul, sense subconsultes, en dos passos:

CREATE TEMP TABLE totals_comanda AS
SELECT lc.comanda_id,
       ROUND(SUM(lc.quantitat * lc.preu_unitari * (1 - lc.descompte)), 2) AS import_productes
FROM   linies_comanda AS lc
GROUP BY lc.comanda_id;
SELECT 20
UPDATE comandes AS co
SET    total = t.import_productes + co.despeses_enviament
FROM   totals_comanda AS t
WHERE  t.comanda_id = co.id
  AND  co.total IS NULL;
UPDATE 20

Fixa't en l'AND co.total IS NULL: fa l'UPDATE idempotent (05-03) i, sobretot, garanteix que el backfill no trepitja els valors que la fase 2 ja està escrivint en temps real. És el detall que converteix un backfill perillós en un de segur.

En una taula real el backfill es faria per lotsWHERE total IS NULL AND id BETWEEN 1 AND 10000, repetit— per no mantenir una transacció gegant bloquejant files.

Fase 4 — Canviar la lectura, i verificar.

Abans que els informes comencin a fer servir la columna nova, cal comprovar que coincideix amb el càlcul en viu:

SELECT co.id,
       co.data_comanda,
       co.estat,
       co.total                                       AS total_columna,
       ROUND(SUM(lc.quantitat * lc.preu_unitari * (1 - lc.descompte)), 2)
           + co.despeses_enviament                    AS total_calculat,
       co.total - (ROUND(SUM(lc.quantitat * lc.preu_unitari * (1 - lc.descompte)), 2)
           + co.despeses_enviament)                   AS diferencia
FROM   comandes       AS co
JOIN   linies_comanda AS lc ON lc.comanda_id = co.id
GROUP BY co.id, co.data_comanda, co.estat, co.total, co.despeses_enviament
ORDER BY co.id
LIMIT 8;
id data_comanda estat total_columna total_calculat diferencia
1 2025-03-04 lliurat 47.05 47.05 0.00
2 2025-03-12 lliurat 26.70 26.70 0.00
3 2025-04-02 lliurat 34.48 34.48 0.00
4 2025-04-19 lliurat 36.70 36.70 0.00
5 2025-05-07 lliurat 32.10 32.10 0.00
6 2025-05-23 cancellat 31.70 31.70 0.00
7 2025-06-11 lliurat 37.10 37.10 0.00
8 2025-06-28 lliurat 74.78 74.78 0.00

(8 primeres de 20 files.)

I la comprovació global, que és la que de debò importa:

SELECT COUNT(*)         AS comandes,
       SUM(total)       AS suma_columna,
       COUNT(*) FILTER (WHERE total IS NULL) AS sense_omplir
FROM   comandes;
comandes suma_columna sense_omplir
20 846.20 0

846,20 €: exactament la xifra canònica del mòdul 4 (727,95 € de producte + 118,25 € de ports). Vint comandes, cap sense omplir. El backfill és correcte.

En aquesta fase, el professional és deixar la comparació funcionant uns dies amb una alerta: si algun cop total deixa de coincidir amb la suma de les línies, és que la doble escriptura té un forat.

Fase 5 — Contract: consolidar i netejar.

ALTER TABLE comandes ALTER COLUMN total SET NOT NULL;
ALTER TABLE

Ara que totes les files tenen valor, la columna pot ser obligatòria. I es retira del codi el càlcul antic.

Queda un problema obert, i convé dir-ho amb claredat: comandes.total és un agregat precalculat i cal mantenir-lo sincronitzat. Si demà algú modifica una línia de comanda amb un UPDATE directe, total queda obsolet i ningú no se n'assabenta. Les tres solucions —un trigger que el recalculi, una vista materialitzada, o una columna generada si la fórmula visqués a la mateixa fila— són del mòdul 10. És exactament l'advertiment de 01-05, secció 8: desnormalitza amb mesures a la mà, i documenta com el mantindràs al dia.

La variant: partir nom i cognoms

El mateix patró, aplicat a un canvi de forma. Suposa que BotigaVerda hagués nascut amb una única columna nom_complet i volguessis separar-la:

Fase Acció
1 · Expand ALTER TABLE clients ADD COLUMN nom VARCHAR(60), ADD COLUMN cognoms VARCHAR(90);
2 · Doble escriptura L'aplicació omple les tres columnes a cada alta i a cada modificació
3 · Backfill UPDATE que parteix nom_complet pel primer espai (funcions de cadena: mòdul 6)
4 · Canviar la lectura Els formularis i llistats passen a fer servir nom i cognoms; es compara amb nom_complet
5 · Contract SET NOT NULL a les dues noves i DROP COLUMN nom_complet

Fixa't en el que mai no es fa: un RENAME COLUMN de cop, ni un DROP COLUMN abans que ningú no la llegeixi. Cada fase és reversible i cap no trenca la versió anterior del codi.

  1. Migracions versionades

Tot l'anterior té un requisit previ que no és tècnic:

Un canvi d'esquema no és una ordre: és un fitxer versionat al repositori.

Què és una migració versionada

Un fitxer numerat, amb nom descriptiu, que conté un canvi d'esquema i (idealment) la seva reversió:

db/migrations/
├── V001__esquema_inicial.sql
├── V002__afegir_telefon_clients.sql
├── V003__afegir_total_comandes.sql
├── V004__backfill_total_comandes.sql
└── V005__total_comandes_not_null.sql

Una eina de migració manté a la mateixa base de dades una taula de control amb les migracions ja aplicades, i en arrencar aplica només les que falten, en ordre.

Per què això és innegociable:

Sense migracions versionades Amb migracions versionades
Ningú no sap quin esquema té cada entorn L'esquema és reproduïble des de zero
Els canvis no passen per revisió de codi Cada canvi és una pull request revisable
No hi ha història: qui va afegir aquella columna i per què? git log respon
Desenvolupament, proves i producció divergeixen Els tres apliquen la mateixa seqüència
Un desplegament pot oblidar el canvi de base El desplegament l'aplica automàticament
Reproduir un error és impossible N'hi ha prou amb clonar el repositori

Eines habituals

Eina Ecosistema Format Reversió
Flyway Java, JVM, CLI SQL pur o Java U (Teams) o manual
Liquibase Java, multiplataforma XML, YAML, JSON o SQL Automàtica en molts casos
Alembic Python (SQLAlchemy) Python (upgrade/downgrade) Explícita, molt usada
Active Record Migrations Ruby on Rails Ruby (change o up/down) Automàtica quan es pot inferir
Django migrations Python (Django) Python autogenerat des dels models Automàtica majoritàriament
Laravel migrations PHP PHP (up/down) Explícita
Sqitch Agnòstica, CLI SQL pur (deploy/revert/verify) Explícita, amb verificació
golang-migrate / dbmate Go, agnòstiques SQL pur (.up.sql / .down.sql) Explícita

Totes resolen el mateix problema i difereixen sobretot en si el canvi s'escriu en SQL o en el llenguatge de l'aplicació. Escriure'l en SQL pur (Flyway, Sqitch, golang-migrate) dóna control total sobre lock_timeout, NOT VALID i CONCURRENTLY; escriure'l en el llenguatge de l'aplicació (Alembic, Rails, Django) dóna portabilitat entre motors i reversió automàtica, al preu de perdre el control fi que necessiten les taules grans.

Les cinc regles d'or

  1. Una migració = un canvi. Si el fitxer fa cinc coses i la tercera falla en producció, no sabràs en quin estat ha quedat (i a MySQL, a més, no ho podràs desfer).
  2. Sempre amb script de reversió. Encara que el teu motor tingui DDL transaccional, escriure el down t'obliga a pensar si el canvi és reversible. Moltes vegades descobriràs que no ho és —un DROP COLUMN no es desfà— i això és informació valuosíssima abans d'aplicar-lo.
  3. Provada en un entorn igual al de producció. No al teu portàtil amb 20 files: en una còpia amb volum realista. Un ALTER COLUMN TYPE que triga 40 ms amb 20 files triga 40 minuts amb 40 milions.
  4. Amb còpia de seguretat prèvia i verificada. I verificada significa restaurada alguna vegada, no "el cron diu que funciona".
  5. Mai a mà a la consola de producció. Ni "només aquesta vegada", ni "és un canvi petit", ni "és urgent". El canvi que no és al repositori no existeix, i el pròxim entorn que es creï no el tindrà.

I un corol·lari que surt de la fase 2 de l'expand/contract: les migracions que trenquen la compatibilitat cap enrere s'han de partir en diverses. V003 afegeix la columna, V004 l'omple, V005 la fa obligatòria — cadascuna desplegable per separat, i entre elles es desplega el codi que les necessita.

  1. Què fer i què no fer en producció

Què NO fer

Per què
ALTER TABLE a mà a la consola de producció No queda registre, no passa per revisió i el pròxim entorn no ho tindrà
DROP COLUMN d'una columna que "sembla que no es fa servir" Si t'equivoques, la dada no torna. Primer deixa de llegir-la durant setmanes, després esborra-la
RENAME COLUMN de cop Trenca tot el codi que l'anomena en l'instant del COMMIT
ALTER COLUMN TYPE en una taula gran en horari de treball Reescriu la taula amb bloqueig exclusiu
ADD CONSTRAINT sense NOT VALID en una taula gran Escaneja la taula sencera amb bloqueig exclusiu
Migrar sense lock_timeout Un ALTER d'1 ms pot encuar tota la càrrega darrere seu
Un fitxer de migració amb quinze canvis Si falla el vuitè, bona sort
Aplicar la migració i desplegar el codi alhora Durant el desplegament conviuen totes dues versions. Expand/contract existeix per això

Què SÍ fer

Per què
Fitxer versionat, revisat en pull request Història, revisió i reproductibilitat
Còpia de seguretat verificada abans L'única xarxa de seguretat real
Provar-la en una còpia amb volum realista Els temps no escalen linealment a la intuïció
SET LOCAL lock_timeout a cada migració Converteix una caiguda en un reintent
NOT VALID + VALIDATE CONSTRAINT en taules grans Protecció immediata sense bloqueig llarg
CREATE INDEX CONCURRENTLY (mòdul 8) Construeix l'índex sense bloquejar escriptures
Backfill per lots i idempotent Transaccions curtes, reexecutable sense mal
Finestra de manteniment per al que reescriu Si alguna cosa ha de trigar, que trigui quan no molesta
Expand/contract per a tot canvi incompatible Zero temps d'aturada
Revisió del responsable de la base de dades Algú que conegui el volum real, la càrrega i les dependències

La regla que resumeix les dues taules: en producció, la pregunta no és "funciona aquest ALTER TABLE?", sinó "què passa mentre s'executa, i què passa si falla a mitges?". Si no saps respondre les dues, la migració no està a punt.

Errors habituals i consells

  • Escriure ALTER TABLE a mà en producció. L'error arrel del qual surten gairebé tots els altres.
  • Creure que ADD COLUMN amb DEFAULT sempre reescriu la taula. Des de PostgreSQL 11 no ho fa, llevat que el DEFAULT sigui volàtil.
  • Creure que una operació "instantània" és inofensiva. Necessita l'ACCESS EXCLUSIVE, i si no l'aconsegueix encua tota la càrrega darrere. lock_timeout, sempre.
  • RENAME de cop. Trenca el codi antic en l'instant del COMMIT. Expand/contract, o no ho facis.
  • ALTER COLUMN TYPE sense USING quan la conversió no és automàtica. El HINT de PostgreSQL et diu exactament què escriure.
  • Reduir un VARCHAR sense comprovar les dades. PostgreSQL avorta; MySQL en mode relaxat trunca en silenci.
  • ADD CONSTRAINT sense NOT VALID en taules grans. Escaneig complet amb bloqueig exclusiu.
  • Esperar que canviar el DEFAULT actualitzi les files existents. No ho fa: només afecta les insercions futures.
  • Fer servir DROP COLUMN com a esborrat segur de dades sensibles. La dada continua en disc fins que la fila es reescrigui.
  • Suposar que el DDL és transaccional a tot arreu. A PostgreSQL, SQL Server i SQLite sí; a MySQL i Oracle, no: una migració a mitges es queda a mitges.
  • Backfill en una sola transacció gegant. Bloqueja files, infla el WAL i si falla cal començar de zero. Per lots.
  • Backfill que trepitja la doble escriptura. Afegeix WHERE columna IS NULL per tocar només el que falta.
  • Migracions sense script de reversió. Escriure'l és la manera més barata de descobrir que el canvi no és reversible.
  • Consell: \d taula abans i després de cada ALTER TABLE. Comprovar el que has fet costa dos segons.
  • Consell: embolcalla les migracions en BEGINCOMMIT si el teu motor ho permet. A PostgreSQL, quinze canvis passen a ser atòmics.
  • Consell: mesura el temps en una còpia amb volum real abans de tocar producció. És la diferència entre una finestra de manteniment de cinc minuts i una de cinc hores.

Exercicis

Treballa sobre la base acabada de recarregar, dins de BEGINROLLBACK.

Exercici 1

BotigaVerda vol registrar el canal pel qual entra cada comanda (web o telefon), una dada que fins ara es deduïa indirectament de si empleat_id era NULL.

  1. Afegeix a comandes una columna canal VARCHAR(10), obligatòria, amb valor per omissió 'web' i protegida amb un CHECK amb nom que només admeti 'web' i 'telefon'.
  2. Omple cap enrere: les comandes amb comercial assignat són 'telefon'; la resta, 'web'.
  3. Comprova el resultat agrupant per canal.
  4. Respon: per què el pas 1 no requereix reescriure la taula? I què hauria passat si haguessis posat el CHECK abans d'omplir les dades?

Exercici 2

Un company et passa aquest fitxer de migració perquè el revisis abans d'aplicar-lo a producció, on productes té 4 milions de files i linies_comanda 90 milions:

-- ⚠️ V017__millores_cataleg.sql
ALTER TABLE productes RENAME COLUMN nom TO denominacio;
ALTER TABLE productes ADD COLUMN sku VARCHAR(20) NOT NULL DEFAULT gen_random_uuid()::text;
ALTER TABLE productes ADD CONSTRAINT uq_productes_sku UNIQUE (sku);
ALTER TABLE productes ALTER COLUMN preu TYPE NUMERIC(14,4);
ALTER TABLE linies_comanda ADD CONSTRAINT chk_lc_import CHECK (quantitat * preu_unitari >= 0);
ALTER TABLE productes DROP COLUMN cost;
  1. Identifica tots els problemes, indicant per a cadascun si és de bloqueig, de compatibilitat, de reversibilitat o de procés.
  2. Estima quines de les sis operacions són instantànies i quines no.
  3. Reescriu la migració com hauria d'estar, partint-la en els fitxers que calgui.

Exercici 3

Aplica el patró expand/contract complet per afegir a clients una columna total_gastat NUMERIC(10,2) que acumuli el que cada client ha comprat (producte + ports).

  1. Escriu les cinc fases, indicant a cadascuna què és canvi d'esquema, què és canvi de codi i què és canvi de dades.
  2. Executa les fases 1, 3 i 5 sobre BotigaVerda (la 2 i la 4 són d'aplicació).
  3. Verifica que la suma de total_gastat de tots els clients coincideix amb el total de comandes del curs.
  4. Discuteix: ha de ser la columna NOT NULL? Quin valor tenen els tres clients sense comandes, i què implica això?

Solucions

Solució 1

BEGIN;

-- 1) Afegir la columna amb el seu DEFAULT i el seu CHECK, en una sola sentència
ALTER TABLE comandes
    ADD COLUMN canal VARCHAR(10) NOT NULL DEFAULT 'web',
    ADD CONSTRAINT chk_comandes_canal CHECK (canal IN ('web', 'telefon'));
ALTER TABLE
-- 2) Backfill: els que tenen comercial van entrar per telèfon
UPDATE comandes
SET    canal = 'telefon'
WHERE  empleat_id IS NOT NULL
  AND  canal <> 'telefon';
UPDATE 10
-- 3) Comprovació
SELECT canal,
       COUNT(*)                        AS comandes,
       COUNT(empleat_id)               AS amb_comercial,
       COUNT(*) - COUNT(empleat_id)    AS sense_comercial,
       SUM(despeses_enviament)         AS ports
FROM   comandes
GROUP BY canal
ORDER BY comandes DESC, canal;
canal comandes amb_comercial sense_comercial ports
telefon 10 10 0 72.15
web 10 0 10 46.10
COMMIT;

Deu i deu, exactament el repartiment que 01-06 descrivia: la meitat del canal és web. I les tres formes de COUNT de 04-04 confirmen la coherència: al canal telefon les 10 comandes tenen comercial, a web cap. Els ports sumen 72,15 € + 46,10 € = 118,25 €, la xifra canònica del mòdul 4 — i de passada revelen una cosa que no s'havia mirat mai: el canal telefònic paga bastants més ports, perquè concentra les comandes a Portugal i França.

4. Les dues preguntes.

Per què no reescriu la taula: el DEFAULT 'web' és una constant, i des de PostgreSQL 11 això es desa al catàleg i es retorna al vol per a les files antigues. Si el valor per omissió hagués estat alguna cosa volàtil, sí que hauria reescrit les 20 files (irrellevant aquí, decisiu amb 20 milions).

Què hauria passat amb el CHECK abans de les dades: en aquest cas concret, res dolent, perquè el DEFAULT 'web' deixa totes les files amb un valor vàlid. Però si haguéssim afegit la columna nul·lable i sense DEFAULT i després el CHECK, tampoc no hauria fallat: les 20 files tindrien NULL, i un CHECK que s'avalua a UNKNOWN s'accepta (05-01, apartat 3.5). El CHECK hauria passat sense protestar i amb la columna buida. És la trampa clàssica: l'ordre importa, però el CHECK no sempre t'avisa que has fet les coses a l'inrevés. El NOT NULL sí que ho hauria fet.

En una taula gran l'ordre correcte i segur seria: afegir la columna nul·lable → backfill per lots → ADD CONSTRAINT ... NOT VALIDVALIDATE CONSTRAINTSET NOT NULL.

Solució 2

1 i 2. Els problemes, operació per operació:

# Operació Instantània Problemes
1 RENAME COLUMN nom TO denominacio Compatibilitat: trenca tot el codi que diu nom en l'instant del COMMIT. Necessita expand/contract
2 ADD COLUMN sku ... DEFAULT gen_random_uuid()::text No Bloqueig: DEFAULT volàtil → reescriu 4 milions de files amb ACCESS EXCLUSIVE. A més, un UUID no és un SKU: és un identificador sense significat per a una columna que hauria de tenir format de negoci
3 ADD CONSTRAINT uq_productes_sku UNIQUE No Bloqueig: construeix un índex únic sobre 4 milions de files amb bloqueig exclusiu. Hauria de ser CREATE UNIQUE INDEX CONCURRENTLY + ADD CONSTRAINT ... USING INDEX
4 ALTER COLUMN preu TYPE NUMERIC(14,4) No Bloqueig: canviar l'escala d'un NUMERIC no és binari-compatible → reescriu la taula i reconstrueix índexs. I de passada canvia la semàntica dels diners del sistema (01-04)
5 ADD CONSTRAINT chk_lc_import sense NOT VALID No Bloqueig: escaneig complet de 90 milions de files amb bloqueig exclusiu. I la restricció és inútil: quantitat > 0 i preu_unitari >= 0 ja ho garanteixen
6 DROP COLUMN cost Reversibilitat: destrueix la dada de la qual surten tots els marges del negoci, sense marxa enrere

I dos problemes de procés que afecten el fitxer sencer:

  • Sis canvis heterogenis en una migració. Viola la primera regla d'or: si falla el quart, quedes a mitges (a PostgreSQL el ROLLBACK et salva; a MySQL, no).
  • Sense lock_timeout. Qualsevol d'aquestes operacions pot encuar tota la càrrega darrere seu.

Estimació de temps amb aquells volums: les operacions 2, 3 i 4 es compten en minuts o hores cadascuna; la 5, en diversos minuts. La migració completa deixaria el catàleg inaccessible durant tot aquell temps.

3. La versió correcta, partida en fitxers:

-- V017__afegir_sku_productes.sql
-- Fase EXPAND. Instantània: columna nul·lable, sense DEFAULT.
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE productes ADD COLUMN sku VARCHAR(20);
COMMIT;
-- V018__backfill_sku_productes.sql
-- Per lots, idempotent. S'executa fora d'hora punta.
-- (El generador real de SKU l'aporta l'aplicació; aquí, un exemple)
UPDATE productes
SET    sku = 'BV-' || LPAD(id::text, 6, '0')
WHERE  sku IS NULL
  AND  id BETWEEN :des_de AND :fins_a;
-- V019__sku_unic_i_obligatori.sql
-- L'índex es construeix SENSE bloquejar escriptures (mòdul 8).
CREATE UNIQUE INDEX CONCURRENTLY uq_productes_sku_idx ON productes (sku);

BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE productes
    ADD CONSTRAINT uq_productes_sku UNIQUE USING INDEX uq_productes_sku_idx;
ALTER TABLE productes ALTER COLUMN sku SET NOT NULL;
COMMIT;

I les tres operacions restants:

Operació Veredicte
RENAME COLUMN nom TO denominacio Rebutjada tal qual. Si de debò cal, expand/contract en quatre migracions i diversos desplegaments. Preguntar abans si el nom nou aporta alguna cosa
ALTER COLUMN preu TYPE NUMERIC(14,4) Rebutjada. Canvia la semàntica dels diners de tot el sistema. Si calgués més precisió per a casos concrets, és una columna nova, no un canvi de tipus
DROP COLUMN cost Rebutjada. Abans: comprovar que cap consulta no la fa servir, deixar de llegir-la durant setmanes, i només llavors plantejar l'esborrat en la seva pròpia migració amb còpia de seguretat verificada
ADD CONSTRAINT chk_lc_import Rebutjada per innecessària. quantitat > 0 i preu_unitari >= 0 ja ho garanteixen. I si tot i així es volgués, NOT VALID + VALIDATE

La lliçó de l'exercici: la major part d'una revisió de migracions consisteix a dir que no. De sis operacions, una està ben plantejada i cinc no s'haurien d'aplicar tal com estan.

Solució 3

1. Les cinc fases:

Fase Què és Acció
1 · Expand Esquema ALTER TABLE clients ADD COLUMN total_gastat NUMERIC(10,2); — nul·lable, instantani
2 · Doble escriptura Codi L'aplicació suma el total de la comanda a clients.total_gastat en confirmar cada compra
3 · Backfill Dades Omplir cap enrere amb l'històric, per lots, només on total_gastat IS NULL
4 · Canviar la lectura Codi Els informes llegeixen la columna; es compara amb el càlcul en viu durant uns dies
5 · Contract Esquema SET DEFAULT 0 i SET NOT NULL; es retira el càlcul antic del codi

2. Execució de les fases 1, 3 i 5:

BEGIN;

-- FASE 1 · EXPAND
ALTER TABLE clients ADD COLUMN total_gastat NUMERIC(10,2);
ALTER TABLE

La temptació és fer-ho d'una sentència, unint clients, comandes i linies_comanda:

-- ⚠️ INCORRECTA: l'error de 04-04, secció 11
SELECT co.client_id,
       SUM(lc.quantitat * lc.preu_unitari * (1 - lc.descompte))
           + SUM(co.despeses_enviament) AS total
FROM   linies_comanda AS lc
JOIN   comandes       AS co ON lc.comanda_id = co.id
GROUP BY co.client_id;

despeses_enviament viu a comandes, i després del JOIN cada comanda apareix tantes vegades com línies tingui: els ports es multiplicarien i la suma total donaria 278,70 € en lloc de 118,25 €. És exactament l'error que el mòdul 3 va advertir tres vegades i el 4 va liquidar.

La forma correcta és agregar en dues etapes, reutilitzant la taula totals_comanda de l'apartat 10:

CREATE TEMP TABLE totals_comanda AS
SELECT lc.comanda_id,
       ROUND(SUM(lc.quantitat * lc.preu_unitari * (1 - lc.descompte)), 2) AS productes
FROM   linies_comanda AS lc
GROUP BY lc.comanda_id;
SELECT 20
CREATE TEMP TABLE despesa_client AS
SELECT co.client_id,
       ROUND(SUM(t.productes + co.despeses_enviament), 2) AS total
FROM   comandes AS co
JOIN   totals_comanda AS t ON t.comanda_id = co.id
GROUP BY co.client_id;
SELECT 12
UPDATE clients AS c
SET    total_gastat = g.total
FROM   despesa_client AS g
WHERE  g.client_id = c.id
  AND  c.total_gastat IS NULL;
UPDATE 12
SELECT c.id,
       c.nom || ' ' || c.cognoms AS client,
       c.pais,
       c.total_gastat
FROM   clients AS c
ORDER BY c.total_gastat DESC NULLS LAST, c.id;
id client pais total_gastat
7 Sofia Moreira Costa Portugal 131.68
1 Lucía Martínez Soler Espanya 112.55
9 Camille Dubois França 95.87
10 Julien Moreau França 79.40
4 Javier Ortega Ruiz Espanya 72.83
6 Pau Llorens Vidal Espanya 68.78
5 Ana Belmonte Roca Espanya 64.75
2 Carlos Ferrer Ibáñez Espanya 59.46
8 Tiago Almeida Nunes Portugal 54.50
12 Diego Ramos Herrera Espanya 36.65
11 Elena Navarro Puig Espanya 35.25
3 Marta Sanchis Gil Espanya 34.48
13 Núria Bosch Ferrer Espanya (null)
14 Hugo Iglesias Pardo Espanya (null)
15 Inés Carrasco Vega Espanya (null)

Sofia Moreira Costa encapçala el rànquing amb 131,68 € en dues comandes, per davant de la Lucía, que n'ha fet tres. I els tres últims, amb NULL, són els clients sense comandes de 01-06.

3. Verificació:

SELECT COUNT(*)                          AS clients,
       COUNT(total_gastat)               AS amb_compres,
       COUNT(*) - COUNT(total_gastat)    AS sense_compres,
       SUM(total_gastat)                 AS total
FROM   clients;
clients amb_compres sense_compres total
15 12 3 846.20

846,20 €: la xifra canònica del curs (727,95 € de producte + 118,25 € de ports). Dotze clients amb compres i tres sense cap, exactament els buits deliberats de 01-06. Les tres formes de COUNT de 04-04 tornen a fer tota la feina de verificació.

-- FASE 5 · CONTRACT
ALTER TABLE clients
    ALTER COLUMN total_gastat SET DEFAULT 0;

UPDATE clients SET total_gastat = 0 WHERE total_gastat IS NULL;
ALTER TABLE
UPDATE 3
ALTER TABLE clients
    ALTER COLUMN total_gastat SET NOT NULL,
    ADD CONSTRAINT chk_clients_total_gastat CHECK (total_gastat >= 0);
ALTER TABLE
COMMIT;

4. La discussió. Ha de ser NOT NULL?

NULL per a qui no ha comprat 0 per a qui no ha comprat
Semàntica "No ha comprat mai" es distingeix de "va comprar i ho va tornar tot" Es confonen els dos casos
Consultes AVG(total_gastat) ignora els tres, donant la mitjana de compradors AVG els inclou i abaixa la mitjana
Ordenació Necessita NULLS LAST explícit Ordena sola
Aritmètica total_gastat + 10 dóna NULL Funciona
Fidelitat al model Més gran Menor

No hi ha resposta universal, i aquesta és la resposta correcta. Depèn de si "no ha comprat" i "ha comprat 0 €" són el mateix fet de negoci. A BotigaVerda no ho són: la Núria, l'Hugo i la Inés no han demanat mai res, i això és informació. Si es vol NOT NULL per comoditat aritmètica, cal documentar que el 0 significa dues coses i afegir una columna comandes_realitzades INTEGER NOT NULL DEFAULT 0 que sí que les distingeixi.

És exactament la discussió de 04-03, secció 11 —dissenyar amb NULL, amb sentinelles o amb NOT NULL— i la prova que una decisió d'esquema no és mai només tècnica.

I el problema obert, el mateix que amb comandes.total: aquesta columna és un agregat precalculat i cal mantenir-la sincronitzada. Cada comanda nova, cada línia modificada i cada devolució la deixen obsoleta. La solució —un trigger, o una vista materialitzada que es refresqui— és del mòdul 10. Mentre no existeixi, la columna és una bomba de rellotgeria, i per això la fase 2 del patró (la doble escriptura) no és opcional: és l'única que evita que la dada es podreixi.

Conclusió del mòdul

ALTER TABLE tanca el mòdul 5 i amb ell, la teva capacitat d'operar sobre una base de dades completa:

  • Les operacions d'ALTER TABLE: ADD COLUMN (instantània, fins i tot amb DEFAULT constant des de PostgreSQL 11, llevat que sigui volàtil), DROP COLUMN (instantània, irreversible i no un esborrat segur), RENAME (instantània i la més perillosa, perquè trenca el codi antic a l'acte), ALTER COLUMN TYPE amb USING per a les conversions no trivials, SET/DROP NOT NULL, SET/DROP DEFAULT —que no toca les files existents—, i ADD/DROP CONSTRAINT, on es cobra la insistència de 05-01 a anomenar-les.
  • NOT VALID + VALIDATE CONSTRAINT: protecció immediata per a les files noves i comprovació de l'històric sense bloquejar lectures ni escriptures. La tècnica estàndard en taules grans.
  • Què bloqueja i què no, amb la taula de nivells; i el detall que de debò tomba producció: una operació instantània que no aconsegueix l'ACCESS EXCLUSIVE encua tota la càrrega darrere seu. SET LOCAL lock_timeout és la protecció.
  • DDL transaccional: a PostgreSQL, SQL Server i SQLite un ALTER TABLE es pot desfer amb ROLLBACK i una migració de quinze passos és atòmica; a MySQL i Oracle no, i una migració que falla a mitges es queda a mitges.
  • Expand/contract: afegir el nou → escriure als dos → omplir cap enrere → canviar la lectura → eliminar el vell. Cinc fases en què mai no existeix un estat que trenqui l'aplicació. Demostrat amb comandes.total, el backfill idempotent del qual (WHERE total IS NULL) va quadrar en els 846,20 € canònics del curs.
  • Migracions versionades: un fitxer numerat per canvi, al repositori, revisat com a codi, aplicat per una eina (Flyway, Liquibase, Alembic, Rails, Django, Laravel, Sqitch). I les cinc regles d'or: una migració = un canvi, sempre amb reversió, provada amb volum realista, amb còpia de seguretat verificada, i mai a mà en producció.

I amb això es tanca el mòdul 5. Repassa el que has guanyat en sis lliçons. Saps crear taules amb CREATE TABLE i declarar les sis restriccions que portaves cinc mòduls llegint, entenent per què CHECK accepta els nuls, per què UNIQUE n'admet diversos i per què GENERATED BY DEFAULT és el que obliga a aquell bloc de setval. Saps inserir amb INSERT, llistant sempre les columnes, en lots, i recuperant amb RETURNING l'id que necessites per penjar-hi les línies d'una comanda. Saps modificar amb UPDATE, amb un protocol de cinc passos i una transacció oberta, entenent que totes les assignacions s'avaluen sobre la fila antiga i que preu * 1.05 no és idempotent. Saps esborrar amb DELETE, distingir-lo de TRUNCATE, predir què s'endú per davant cada ON DELETE, i —el més important— decidir si cal esborrar, perquè actiu = FALSE existeix just per no fer-ho. Saps fusionar amb ON CONFLICT i amb MERGE, i per què mirar-i-després-decidir no és mai segur. I saps canviar l'esquema sense tombar el servei.

Ha canviat alguna cosa més que el repertori d'instruccions: ha canviat el nivell de responsabilitat. Als mòduls 2 a 4, un error teu retornava un número equivocat. Des d'aquest mòdul, un error teu modifica dades reals. Per això aquí has après tants hàbits com sintaxi: escriure el SELECT abans que l'UPDATE, comptar les files, obrir la transacció, comprovar el RETURNING, fer còpia abans, posar lock_timeout, versionar la migració. Cap d'aquells hàbits no apareix a la referència del llenguatge, i tots separen qui porta anys tocant bases de dades de qui en porta un mes.

Amb BotigaVerda ja ho pots fer tot: consultar-la, creuar-la, agregar-la, poblar-la, corregir-la i fer-la evolucionar. El que encara no pots és transformar i presentar el que en treus. Continues retornant nom i cognoms en dues columnes quan en vols una; continues escrivint ROUND(...) a mà sense conèixer-ne les germanes; continues sense poder extreure l'any d'una data, calcular l'antiguitat d'un client, posar un text en majúscules, convertir un tipus en un altre de manera explícita, substituir un NULL per un valor llegible, ni classificar files en categories segons una condició. Al mòdul 6, Funcions, arriben les eines que falten: funcions de cadena per compondre i netejar text, funcions numèriques per arrodonir i calcular amb criteri, funcions de data i hora per respondre per fi a "quants dies va trigar aquella comanda?", CAST i COALESCE per convertir tipus i domar els nuls que portes dos mòduls esquivant, i CASE per ficar lògica condicional dins d'una consulta. A partir d'aquí, les teves consultes deixaran de retornar dades i començaran a retornar respostes.

Curs de SQL

Mòdul 1: Introducció a SQL

Mòdul 2: Consultes bàsiques de SQL

Mòdul 3: Treballar amb múltiples taules

Mòdul 4: Filtratge avançat de dades

Mòdul 5: Manipulació de dades

Mòdul 6: Funcions avançades de SQL

Mòdul 7: Subconsultes i consultes imbricades

Mòdul 8: Índexs i optimització del rendiment

Mòdul 9: Transaccions i concurrència

Mòdul 10: Temes avançats

Mòdul 11: SQL a la pràctica

Mòdul 12: Projecte final

© Copyright 2026. Tots els drets reservats