Databaseændringer uden svedige håndflader

Databaseændringer uden svedige håndflader

Lav aldrig schema-ændringer i production først.

Første gang jeg ødelagde en database

Jeg starter med den korte version: jeg kørte et manuelt ALTER TABLE direkte på en production database en sen aften. Det virkede i 3 sekunder. Så ringede telefonen.

En kollega: “Øh, alle ordrer fejler lige nu”.

Jeg havde ændret en kolonne, som koden stadig forventede fandtes med det gamle navn. Ingen migrations, ingen versionering, ingen rollback-plan. Bare håb.

Det er præcis det, database migrations prøver at redde dig fra. Især hvis du er ny i backend eller først lige er begyndt på databaser.

Manuel SQL vs migrations – hvad er forskellen i praksis?

Forestil dig to scenarier.

Scenarie A: Manuel SQL direkte i production

Du SSH’er ind på serveren. Logger ind i databasen. Skriver noget a la:

ALTER TABLE users ADD COLUMN phone VARCHAR(20);

Hvis du er lidt mere modig (eller træt):

ALTER TABLE users CHANGE COLUMN name full_name VARCHAR(255);

Der er ingen historik. Ingen versionering. Ingen der ved præcis, hvad du gjorde, ud over dig selv og din shell-historik. Og hvis du laver en fejl, skal du selv huske, hvad du gjorde, og prøve at lave et modsvarende ALTER TABLE.

Scenarie B: Database migrations som kode

I stedet for skriver du en migration-fil, der lever sammen med din kode, bliver committed og kan køres på alle miljøer:

-- 20260101_add_phone_to_users.sql
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

Eller med et ORM-tool som Sequelize:

module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.addColumn('users', 'phone', {
      type: Sequelize.STRING(20),
      allowNull: true
    });
  },
  down: async (queryInterface, Sequelize) => {
    await queryInterface.removeColumn('users', 'phone');
  }
};

Migrations-filen bliver en lille tidskapsel over, hvad der er sket med databasen. Du kan:

  • køre den på lokal, staging og production
  • se rækkefølgen af ændringer
  • ofte rulle tilbage med en down-funktion

Sammenligning: ad hoc vs kontrolleret

Manuel SQL i production Migrations som kode
Ingen historik, du glemmer hvad du gjorde Alt ligger i git sammen med resten af koden
Du kan ikke nemt genskabe lokalt Samme migration kører på lokal, staging og prod
Rollback er manuelt gætteri Rollback kan beskrives i en down-funktion
Fejl opdages først i production Fejl opdages på lokal/staging inden deploy
Afhænger af én persons hukommelse Afhænger af versionskontrol og værktøjer

Så hvis du tænker “database migrations for begyndere” betyder avanceret DevOps: nej. Det er faktisk den mere trygge, begyndervenlige måde at ændre en database på.

Fire ord du skal kende før du rører databasen

Inden vi bygger noget, skal vi lige have et lille mini-ordforråd på plads.

Schema

Schema er strukturen på din database:

  • hvilke tabeller der findes (f.eks. users, orders)
  • hvilke kolonner hver tabel har (f.eks. email, created_at)
  • hvilke typer de har (f.eks. VARCHAR, INT, TIMESTAMP)

Når du ændrer schemaet, ændrer du strukturen på data, ikke selve rækkerne af data.

Migration

En migration er en lille beskrivelse af en schemaændring.

Typiske eksempler:

  • tilføje en kolonne
  • ændre en kolonne fra NULL til NOT NULL
  • oprette eller slette en tabel
  • oprette eller fjerne et indeks

Du kan se en migration som en commit i git, bare for databasen.

Seed

Seed betyder at fylde databasen med startdata.

Eksempel:

  • oprette en admin-bruger
  • indsætte basissæt af produkter eller roller

Seeds kan køres igen og igen (idempotent), typisk ved at tjekke “findes denne række allerede?” før man indsætter.

Rollback

Rollback migration betyder: kør ændringen baglæns.

Hvis en up-migration tilføjer en kolonne, vil en down-migration fjerne den kolonne igen:

-- up
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

-- down
ALTER TABLE users DROP COLUMN phone;

Rollback lyder rart, men i praksis bruger mange teams det mindre, end man tror. Mere om det senere.

Et sikkert migrations-workflow i 6 trin

Her er en simpel pipeline, du kan bruge uanset om du bruger raw SQL, Prisma, Sequelize eller noget andet.

1. Start med koden, ikke databasen

Lad være med at starte i din database-klient.

Start i dit projekt:

  • lav en ny migration-fil (via CLI eller manuelt)
  • beskriv ændringen i kode eller SQL
  • commit filen

Pointen er, at alt hvad der ændrer databasen, kan spores i git. Ikke i din SSH-historik.

2. Kør migration lokalt først

På din egen maskine:

  • kør migrationen
  • start appen
  • ram de sider eller API endpoints der bruger den ændrede data

Finder du fejl her, har du vundet. Du har ødelagt ingenting andre end din lokale database.

3. Kør tests (hvis du har nogen)

Hvis du har automatiske tests, så sørg for, at migrations kan køre som en del af test-setup.

Et simpelt mønster:

  1. drop test-databasen
  2. kør alle migrations fra 0
  3. kør dine tests

Det sikrer, at en ny udvikler der henter projektet ned, kan genskabe databasen fra bunden.

4. Kør migration på staging

Staging er din generalprøve. Her får du svar på:

  • hvor lang tid migreringen tager med rigtige datamængder
  • om queries låser tabeller for længe
  • om appen faktisk virker mod det nye schema i et miljø tæt på production

Hvis du ikke har staging, så overvej i det mindste en kopi af production-data lokalt (anonymiseret, hvis der er persondata).

5. Deploy kode og migration kontrolleret

Her findes to klassiske mønstre:

  • Deploy kode først, migrations bagefter
  • Kør migrations som en del af deploy-step i din CI/CD

Vigtigt: din kode og dit schema skal være kompatible i overgangsperioden. Dvs. at din kodede ændring ikke må forvente det nye felt, før migrationen er kørt, og omvendt.

6. Tjek efter deploy

Når migrationen har kørt i production:

  • tjek logs for fejl (f.eks. manglende kolonner, SQL-fejl)
  • kør et par manuelle requests gennem UI eller API
  • hav en plan for, hvad du gør, hvis der opstår fejl de næste 10-30 minutter

Database migrations handler mindre om SQL-syntaks og mere om, at du har en plan, du kan gentage.

Eksempel: Tilføj kolonne + backfill uden downtime

Lad os tage en klassisk ændring mange løber ind i:

Du har en orders-tabel uden et felt for “status”. Du vil gerne have et status-felt (pending, paid, cancelled osv.) og gerne udfylde gamle ordrer med en fornuftig standardværdi.

Trin 1: Tænk i små skridt, ikke én stor ændring

Du kan gøre det på to måder.

Dårlig version:

ALTER TABLE orders
  ADD COLUMN status VARCHAR(20) NOT NULL,
  ALTER COLUMN payment_date SET NOT NULL,
  DROP COLUMN legacy_state,
  ...;

Alt i én stor, tung migration. Hvis noget fejler midt i den, er du halvvejs.

Bedre version: flere små, sekventielle migrations:

  1. Tilføj kolonne som NULL og uden constraints
  2. Backfill data i små bidder
  3. Opdater kode til at bruge det nye felt
  4. Gør kolonnen NOT NULL, når du er sikker

Trin 2: Migration 1 – tilføj kolonnen

SQL-version:

-- 001_add_status_column_to_orders.sql
ALTER TABLE orders
  ADD COLUMN status VARCHAR(20);

Læg mærke til: ingen NOT NULL endnu. Kolonnen er frivillig i starten.

Trin 3: Backfill data sikkert

Hvis du har 500 rækker, kan du ofte slippe afsted med et enkelt statement:

UPDATE orders
SET status = 'paid'
WHERE payment_date IS NOT NULL
  AND status IS NULL;

UPDATE orders
SET status = 'pending'
WHERE payment_date IS NULL
  AND status IS NULL;

Hvis du har 5 millioner rækker, vil du typisk køre det i batches:

UPDATE orders
SET status = 'paid'
WHERE payment_date IS NOT NULL
  AND status IS NULL
LIMIT 1000;

Og så køre det flere gange med et lille script eller manuelt. Pointen er at undgå at låse hele tabellen alt for længe.

Trin 4: Opdater koden gradvist

Din backend-kode skal nu begynde at skrive til status-feltet for nye ordrer.

En simpel model i f.eks. JS/TypeScript:

// Før
const status = paymentDate ? 'paid' : 'pending';

// Efter - nu gemmer du det i kolonnen
await db.order.create({
  data: {
    ...,
    status: paymentDate ? 'paid' : 'pending'
  }
});

Læsning kan i en overgangsperiode være defensiv:

function getOrderStatus(order) {
  if (order.status) return order.status;
  return order.payment_date ? 'paid' : 'pending';
}

På den måde går du ikke i stykker, hvis nogle rækker ikke har status udfyldt endnu.

Trin 5: Gør kolonnen obligatorisk, når du er klar

Når:

  • alle eksisterende rækker har en status
  • ny kode altid sætter status

så kan du lave en ny migration:

-- 002_make_status_not_null.sql
ALTER TABLE orders
  ALTER COLUMN status SET NOT NULL;

Nu har du opnået målet: et obligatorisk status-felt, udfyldt bagudrettet, uden at knække appen.

Typisk fejl: for stramme constraints for tidligt

Jeg ser mange begyndere (og ærligt, også øvede) lave den her:

ALTER TABLE orders
  ADD COLUMN status VARCHAR(20) NOT NULL;

Hvis databasen kræver en værdi for eksisterende rækker, fejler den. Eller hvis databasen tillader en default, får alle gamle rækker måske '' eller en mærkelig standardværdi.

Bedre: acceptér lidt rod i en kort periode, men med en plan for at rydde op, og først bagefter stramme constraints.

Når noget går galt – rollback eller fremadrettet fix?

Rollback lyder som en “fortryd”-knap. I virkeligheden er det ofte mere kompliceret, især hvis der er kommet ny data efter migrationen.

Hvornår rollback giver mening

Rollback migration er fint, hvis:

  • ingen eller meget få har brugt systemet efter ændringen
  • du kun har ændret struktur, ikke slettet data
  • din down-funktion faktisk genskaber den oprindelige tilstand

Eksempel på harmløs situation:

  • du tilføjede en ekstra kolonne, men koden bruger den ikke endnu
  • du ser en fejl kort efter deploy
  • du ruller både kode og migration tilbage

Hvornår rollback er farligt

Forestil dig at du:

  • tilføjer en kolonne
  • kører migrations
  • brugere opretter nye data, hvor feltet bliver udfyldt
  • du ruller tilbage og fjerner kolonnen

Så har du lige smidt nye, rigtige data ud.

Forward fix: typisk bedre end rollback

Derfor vælger mange teams en anden strategi:

  • rul koden tilbage (så appen ikke bruger den nye struktur mere)
  • lad databasen stå, som den er
  • lav en ny migration, der retter fejlen (en “forward fix”)

Eksempel:

  • du har stavet en kolonne forkert i migrationen (stauts i stedet for status)
  • din kode crasher

I stedet for rollback kan du lave en ny migration:

ALTER TABLE orders RENAME COLUMN stauts TO status;

Nu er historikken måske ikke super køn, men data overlever, og alting stemmer igen.

Prisma, Sequelize, Flyway – hvem gør hvad?

Der findes mange værktøjer til migrations. Her er en hurtig sammenligning, så du har et mental kort.

Prisma migrations

Prisma Migrate er godt hvis du:

  • i forvejen bruger Prisma som ORM
  • godt kan lide at beskrive schema i en schema.prisma-fil
  • vil have at værktøjet genererer SQL migrations for dig

Typisk workflow:

  1. ændr schema.prisma
  2. kør npx prisma migrate dev --name add-status-to-orders
  3. Prisma genererer migrations-filer og kører dem mod databasen

Du får både en human-læselig schemafil og versionerede migrations.

Sequelize migrations

Sequelize migrations er klassikeren i Node/Express-projekter.

Her skriver du migrationer som JS-filer med up og down funktioner, typisk via sequelize-cli.

Fordele:

  • du kan bruge JS/TS til at kontrollere flowet
  • integreres godt med resten af et JS-projekt

Ulemper:

  • mindre tydelig global schema-definition
  • du skal selv holde model-definitions og migrations nogenlunde i sync

Flyway (og lignende tools)

Flyway er mere database-centreret og sprog-agnostisk.

Du skriver typisk rene SQL-filer med versionsnumre, f.eks.:

V1__create_users_table.sql
V2__add_status_to_orders.sql

Flyway holder styr på, hvilke versioner der er kørt i databasen, og kører de næste i rækken.

Det her giver mening hvis:

  • du arbejder i et polyglot-miljø (flere sprog, samme DB)
  • du vil være helt tæt på din SQL

Hvad skal du vælge som begynder?

En simpel tommelfingerregel:

  • Bruger du allerede Prisma: brug Prisma Migrate
  • Bruger du Sequelize: brug Sequelize migrations
  • Ellers: overvej rene SQL-filer med et simpelt værktøj som Flyway

Det vigtigste er ikke værktøjet, men at du har et fast mønster. Hvis du vil have mere kontekst om backend og databasesamspil, kan du også kigge på artiklen om SQL indeks og queries på Coding Class.

Tjekliste før du kører migrations i production

Her er den liste, jeg selv mere eller mindre går efter, før jeg trykker på knappen.

1. Er migrationen kørt lokalt uden fejl?

Hvis svaret er “nej” eller “jeg har kun læst den igennem”: stop.

Kør den mod din lokale database. Smadr den, hvis det er det, der skal til. Det er træningsbanen.

2. Virker koden mod den nye struktur?

Start appen efter migrationen og lav en mini-rundtur:

  • opret noget nyt data
  • læs det ud igen
  • opdater og slet, hvis relevant

Hvis det er et API-projekt, så brug f.eks. curl, Postman eller et lille script.

3. Har du testet med flere data end din seed?

En tom database lyver ofte. Alt virker, fordi alt er småt og rent.

Prøv mindst én af disse:

  • brug staging med realistiske datamængder
  • lav en anonymiseret kopi af production-data til test

Især vigtigt for tunge ændringer som at tilføje indeks eller ændre store tabeller.

4. Er migrationen opdelt i små bider?

Spørg dig selv:

  • Kan jeg beskrive hvad denne migration gør i én linje?
  • Eller prøver jeg at redesigne et helt schema i ét hug?

Små migrations er nemmere at forstå, debugge og rulle frem eller tilbage.

5. Ved du, hvad du gør, hvis det går galt?

Helt konkret:

  • Skal du rulle koden tilbage?
  • Har du en rollback-migration klar? Og er den sikker?
  • Ved du, hvem der kan hjælpe, hvis du låser en tabel i 10 minutter?

Hvis planen er “jeg håber bare, det virker”, så er det ikke en plan.

6. Er tidspunktet for deploy fornuftigt?

Det lyder kedeligt, men tid betyder noget.

  • Er der lav trafik, når du deployer?
  • Er der nogen online, der kan opdage fejl hurtigt?
  • Er du selv vågen om en time, hvis noget først viser sig senere?

At lave tunge migrations fredag kl. 16 er sådan en klassisk fejl, man kun laver et par gange.

Et sidste lille sammenligningsskema til din mentale model

Hvis du stadig tænker “kan jeg ikke bare køre lidt SQL og være færdig?”, så får du en sidste A vs B til værktøjskassen.

“Bare kør lidt SQL” “Brug migrations”
Hurtigt på små eksperimenter Skalerer bedre, når projektet vokser
Du husker ikke præcis hvad du gjorde Historik ligger i git, så andre kan læse med
Svære at genskabe for nye udviklere Nye kan køre alle migrations fra 0 og være oppe at køre
Rollback er manuelt og risikabelt Rollback eller forward fix kan planlægges
Fristende at lave “små” ændringer direkte i prod Tvinger dig til at tænke flow: lokal → staging → prod

Hvis du lige nu er i gang med dit første rigtige backend-projekt, er det et godt tidspunkt at få migrations ind som en vane. Lidt ligesom at lære logs før du stoler på din egen hukommelse.

Og hvis du allerede én gang har stået med en halvsmadret production-database, så ved du, hvorfor det her er tiden værd. Næste gang kan du nøjes med at smadre din lokale.

Hvis du vil bygge videre, er næste naturlige skridt at kigge på ting som deploy-flow og environments. Artiklen om “Hvis det kun virker på din maskine, virker det ikke” på Coding Class hænger ret godt sammen med hele migrations-tankegangen.

Brug en trinvis fremgang: tilføj først en ny, nullable kolonne; deploy kode der både kan læse fra og skrive til begge kolonner; backfill data asynkront; skift så læsning over og fjern den gamle kolonne. For meget store ændringer kan du også bruge online schema-change værktøjer (fx gh-ost, pt-online-schema-change for MySQL) eller planlagte batcher for at undgå lange låsninger.
Antag at visse ændringer er forward-only: lav altid backup før deploy, eksporter relevante data eller skriv en 'safety copy' i en ny tabel før du sletter. Overvej feature flags, så du kan slukke funktionalitet uden at genskabe skematiske data, og undgå at droppe kolonner før du er helt sikker på, at data ikke længere bruges.
Kør dine migrations automatisk i CI mod en database der matcher produktions engine og version, både up og down, og brug realistiske testdata eller et skaleret subset af produktion. Inkluder migrations i PR-review, kør smoke tests på staging og verificer at skema og data opfører sig som forventet før deploy.
Brug en klar navngivningskonvention (fx timestamp-prefix) og review migrations som kode for at undgå dobbelte ændringer. Hvis der opstår konflikt, lav en ny migration der justerer det samlede skema i stedet for at redigere en allerede deployet migration, og koordiner større skemaændringer via korte synkroniseringsmøder.

Lasse Falkenberg er typen, der begyndte at rode med HTML og CSS for at lave en simpel bandside – og opdagede, at det var langt sjovere at få knapperne til at virke end at stå på scenen. Siden har han kastet sig over alt fra små JavaScript-snippets til Python-scripts, der kan spare ham for kedeligt, manuelt arbejde i hverdagen.

Han har lært det meste ved at bygge ting, der lige præcis løser hans egne problemer: en lille webapp til at holde styr på brætspilsaftener, et script til at rydde op i rodede mapper, eller en enkel side til at dele noter med venner. Undervejs har han kæmpet sig gennem alle de klassiske fejl – semikolon, forkerte indrykninger og variabler, der hedder noget helt andet end man tror – og det er præcis den rejse, han deler på Coding Class.

På Coding Class skriver Lasse praktiske, jordnære guides, der tager udgangspunkt i små, konkrete opgaver: noget du kan se, teste og bygge videre på med det samme. Han elsker at bryde en opgave ned i små bidder, vise den fulde kode og forklare linje for linje, hvad der sker – inklusive de typiske bugs, du med stor sandsynlighed også støder på.

For Lasse handler kodning ikke om flotte titler eller store ord, men om følelsen af at få noget til at virke – og om at du som læser kan gå derfra med noget, du selv har bygget. Hvis du kan kende glæden ved at få en fejl til endelig at forsvinde, er du lige på bølgelængde med hans måde at lære fra sig på.

1 kommentar

comments user
Kirsten Møller

Haha
‘alle ordrer fejler lige nu’ fik mig til at svede!

Send kommentar

You May Have Missed