Leer hoe je schemawijzigingen zonder downtime uitvoert met het expand/contract-patroon: voeg veilig kolommen toe, backfill in batches, deploy compatibele code en verwijder daarna het oude pad.

Downtime door een databasewijziging is niet altijd een duidelijke, zichtbare storing. Voor gebruikers kan het lijken op een pagina die eindeloos laadt, een mislukte checkout, of een app die plotseling "er is iets misgegaan" toont. Voor teams komt het terug als alerts, stijgende foutpercentages en een achterstand aan mislukte writes die opgeschoond moeten worden.
Schemawijzigingen zijn riskant omdat de database gedeeld wordt door elke draaiende versie van je app. Tijdens een release heb je vaak oude en nieuwe code tegelijk actief (rolling deploys, meerdere instanties, achtergrondjobs). Een migratie die op papier klopt kan toch één van die versies breken.
Veelvoorkomende faalpatronen zijn:
Zelfs als de code in orde lijkt, blokkeren releases omdat het echte probleem timing en compatibiliteit tussen versies is.
Zero-downtime schemawijzigingen komen neer op één regel: elke tussenliggende staat moet veilig zijn voor zowel oude als nieuwe code. Je verandert de database zonder bestaande reads en writes kapot te maken, shipped code die beide vormen aankan, en verwijdert het oude pad pas als niemand er meer van afhankelijk is.
Die extra moeite betaalt zich uit als je echt verkeer hebt, strikte SLA's, of veel app-instanties en workers. Voor een klein intern tooltje met weinig database-activiteit is een geplande onderhoudsvenster misschien eenvoudiger.
De meeste incidenten door databasewerk gebeuren omdat de app verwacht dat de database meteen verandert, terwijl de databasewijziging tijd kost. Het expand/contract-patroon voorkomt dat door één risicovolle wijziging op te breken in kleinere, veilige stappen.
Voor een korte periode ondersteunt je systeem twee "dialecten" tegelijk. Je introduceert eerst de nieuwe structuur, houdt de oude in stand, verplaatst data geleidelijk, en ruimt daarna op.
Het patroon is simpel:
Dit speelt goed samen met rolling deploys. Als je 10 servers één voor één bijwerkt, draaien oude en nieuwe versies kort samen. Expand/contract zorgt dat beide compatibel zijn met dezelfde database tijdens die overlap.
Het maakt rollbacks ook minder eng. Als een nieuwe release een bug heeft, kun je de app terugrollen zonder de database terug te draaien, omdat de oude structuren tijdens het expand-window nog bestaan.
Voorbeeld: je wilt een PostgreSQL-kolom full_name splitsen in first_name en last_name. Je voegt de nieuwe kolommen toe (expand), shipt code die beide vormen kan lezen en schrijven, backfilt oude rijen, en dropt full_name pas als je zeker weet dat niemand het meer gebruikt (contract).
De expand-fase draait om het toevoegen van nieuwe opties, niet om het weghalen van oude.
Een veelvoorkomende eerste stap is het toevoegen van een nieuwe kolom. In PostgreSQL is het meestal het veiligst om deze nullable en zonder default toe te voegen. Het toevoegen van een non-null kolom met default kan een table rewrite of zwaardere locks triggeren, afhankelijk van je Postgres-versie en de precieze wijziging. Een veiliger volgorde is: add nullable, deploy tolerante code, backfill, en pas later NOT NULL afdwingen.
Indexen vragen ook om voorzichtigheid. Het aanmaken van een standaard index kan writes langer blokkeren dan je verwacht. Gebruik waar mogelijk concurrent index creatie zodat reads en writes door blijven gaan. Het duurt langer, maar voorkomt release-stoppende locks.
Expand kan ook betekenen dat je nieuwe tabellen toevoegt. Als je van één kolom naar een many-to-many-relatie gaat, voeg je mogelijk een join-tabel toe terwijl je de oude kolom laat bestaan. Het oude pad blijft werken terwijl de nieuwe structuur data begint te verzamelen.
In de praktijk omvat expand vaak:
Na expand moeten oude en nieuwe appversies tegelijk kunnen draaien zonder verrassingen.
De meeste release-pijn ontstaat in het midden: sommige servers draaien nieuwe code, anderen draaien nog oude code, terwijl de database al verandert. Je doel is eenvoudig: elke versie tijdens de rollout moet werken met zowel het oude als het uitgebreide schema.
Een veelgebruikte aanpak is dual-write. Als je een nieuwe kolom toevoegt, schrijft de nieuwe app naar zowel de oude als de nieuwe kolom. Oude appversies blijven alleen naar de oude schrijven, wat geen probleem is omdat die kolom er nog is. Houd de nieuwe kolom in eerste instantie optioneel en stel strikte constraints uit totdat je zeker weet dat alle writers zijn geüpgraded.
Reads schakelen meestal voorzichtiger dan writes. Hou reads eerst op de oude kolom (de kolom die je weet volledig is gevuld). Na backfill en verificatie schakel je reads over naar de nieuwe kolom, met een fallback naar de oude als de nieuwe ontbreekt.
Zorg er ook voor dat je API-uitvoer stabiel blijft terwijl de database eronder verandert. Zelfs als je een intern nieuw veld introduceert, vermijd het meteen veranderen van response-vormen totdat alle consumenten klaar zijn (web, mobiel, integraties).
Een rollback-vriendelijke rollout ziet er vaak zo uit:
Het belangrijkste idee is dat het eerste onomkeerbare stap het droppen van de oude structuur is, dus die stel je uit tot het einde.
Backfilling is waar veel "zero-downtime schema changes" misgaan. Je wilt de nieuwe kolom vullen voor bestaande rijen zonder lange locks, trage queries of onverwachte loadspikes.
Batching is belangrijk. Streef naar batches die snel klaar zijn (seconden, niet minuten). Als elke batch klein is kun je de job pauzeren, hervatten en tunen zonder releases te blokkeren.
Gebruik een stabiele cursor om voortgang bij te houden. In PostgreSQL is dat vaak de primaire sleutel. Verwerk rijen op volgorde en sla de laatste afgeronde id op, of werk in id-bereiken. Dat voorkomt dure full-table scans bij een herstart van de job.
Hier is een simpel patroon:
UPDATE my_table
SET new_col = ...
WHERE new_col IS NULL
AND id > $last_id
ORDER BY id
LIMIT 1000;
Maak de update conditioneel (bijvoorbeeld WHERE new_col IS NULL) zodat de job idempotent is. Heruitvoeringen raken alleen rijen die nog werk nodig hebben, wat onnodige writes vermindert.
Plan voor nieuwe data die binnenkomt tijdens de backfill. De gebruikelijke volgorde is:
Een goede backfill is saai: gestaag, meetbaar en makkelijk te pauzeren als de database heet wordt.
Het risicovolle moment is niet het toevoegen van de nieuwe kolom. Het is beslissen dat je erop kunt vertrouwen.
Voordat je naar contract gaat, bewijs twee dingen: de nieuwe data is compleet, en productie leest die veilig.
Begin met volledigheidschecks die snel en herhaalbaar zijn:
Als je dual-writing gebruikt, voeg dan een consistentiecheck toe om stille bugs te vangen. Bijvoorbeeld: draai elk uur een query die rijen vindt waar old_value <> new_value en alarm als dat niet nul is. Dit is vaak de snelste manier om te ontdekken dat een writer nog steeds alleen het oude veld bijwerkt.
Houd basisproductiesignalen in de gaten terwijl de migratie loopt. Als querytijd of lock-waits pieken, kunnen zelfs je "veilige" verificatiequeries extra load toevoegen. Monitor foutpercentages voor alle codepaden die de nieuwe kolom lezen, vooral direct na deploys.
Hoe lang je beide paden houdt? Lang genoeg om minstens één volledige releasecyclus en één backfill-heruitvoering te overleven. Veel teams gebruiken 1–2 weken, of totdat ze er zeker van zijn dat er geen oude appversie meer draait.
Contract is waar teams nerveus van worden omdat het als het punt zonder terugkeer voelt. Als expand goed is gedaan, is contract vooral opruimen en kun je het in kleine, lage-risico stappen doen.
Kies het moment zorgvuldig. Verwijder niets direct na een backfill. Wacht minstens één volledige releasecyclus zodat delayed jobs en randgevallen zich kunnen blootgeven.
Een veilige contractvolgorde ziet er meestal zo uit:
Als het kan, splits contract in twee releases: één die code-referenties verwijdert (met extra logging), en een latere die database-objecten verwijdert. Die scheiding maakt rollback en troubleshooting veel eenvoudiger.
PostgreSQL-specifieke details zijn hier van belang. Een kolom droppen is meestal een metadata-wijziging, maar het neemt nog steeds kort een ACCESS EXCLUSIVE lock. Plan een rustig moment en houd de migratie snel. Als je extra indexen hebt gemaakt, geef de voorkeur aan DROP INDEX CONCURRENTLY om writes niet te blokkeren (dat kan niet in een transaction block, dus je migratie-tooling moet dat ondersteunen).
Zero-downtime migraties falen wanneer de database en de app ophouden overeen te komen wat is toegestaan. Het patroon werkt alleen als elke tussenliggende staat veilig is voor zowel oude als nieuwe code.
Deze fouten komen vaak voor:
Een realistisch scenario: je begint full_name vanuit de API te schrijven, maar een achtergrondjob die users aanmaakt zet alleen first_name en last_name. Die job draait 's nachts, voegt rijen in met full_name = NULL, en latere code gaat ervan uit dat full_name altijd aanwezig is.
Behandel elke stap als een release die dagen kan duren:
Een herhaalbare checklist voorkomt dat je code pusht die alleen in één databasesituatie werkt.
Voor je deploy, bevestig dat de database al de uitgebreide onderdelen heeft (nieuwe kolommen/tabellen, indexen op een laag-lock manier aangemaakt). Controleer vervolgens dat de app tolerant is: het moet werken tegen het oude, het uitgebreide en een half-backfilled state.
Houd de checklist kort:
Een migratie is pas gedaan als reads de nieuwe data gebruiken, writes de oude data niet meer onderhouden, en je de backfill hebt geverifieerd met ten minste één eenvoudige check (counts of sampling).
Stel dat je een PostgreSQL-tabel customers hebt met een kolom phone die rommelige waarden bevat (verschillende formaten, soms leeg). Je wilt die vervangen door phone_e164, maar je kunt geen releases blokkeren of de app offline halen.
Een nette expand/contract-volgorde ziet er zo uit:
phone_e164 toe als nullable, zonder default en zonder zware constraints.phone als phone_e164 te schrijven, maar houd reads op phone zodat er niets verandert voor gebruikers.phone_e164 leest en terugvalt op phone als het nog NULL is.phone_e164 gebruikt, verwijder de fallback, drop phone en voeg indien nodig strengere constraints toe.Rollback blijft eenvoudig zolang elke stap achterwaarts compatibel is. Als de lees-switch problemen veroorzaakt, rol je de app terug en heeft de database nog steeds beide kolommen. Als backfill loadpieken veroorzaakt, pauzeer je de job, verklein je de batchgrootte en ga je later verder.
Wil je dat het team op één lijn blijft, documenteer dan het plan op één plek: de exacte SQL, welke release reads flippt, hoe je voltooiing meet (bijv. percentage non-NULL phone_e164), en wie eigenaar is van elke stap.
Expand/contract werkt het beste als het routine voelt. Schrijf een kort runbook dat je team voor elke schemawijziging kan hergebruiken, bij voorkeur één pagina en specifiek genoeg dat een nieuwe collega het kan volgen.
Een praktisch template bevat:
Bepaal eigenaarschap van tevoren. "Iedereen dacht dat iemand anders contract zou doen" is hoe oude kolommen en featureflags maandenlang blijven bestaan.
Zelfs als de backfill online draait, plan hem op een moment met minder verkeer. Het is makkelijker om batches klein te houden, DB-load te monitoren en snel te stoppen als latency stijgt.
Als je bouwt en deployed met Koder.ai (koder.ai), kan Planning Mode handig zijn om de fasen en checkpoints in kaart te brengen voordat je productie aanraakt. Dezelfde compatibiliteitsregels gelden, maar het opschrijven van de stappen maakt het moeilijker om de saaie dingen over te slaan die outages voorkomen.
Omdat je database gedeeld wordt door alle draaiende versies van je app. Tijdens rolling deploys en achtergrondjobs kunnen oude en nieuwe code tegelijk draaien, en een migratie die namen verandert, kolommen verwijdert of constraints toevoegt kan een van die versies breken die niet op die specifieke schema-toestand is geschreven.
Het betekent dat je de migratie zo ontwerpt dat elke tussenliggende databasesituatie werkt voor zowel oude als nieuwe code. Je voegt eerst nieuwe structuren toe, draait een periode met beide paden, en verwijdert de oude structuren pas als er niets meer van afhankelijk is.
Expand voegt nieuwe kolommen, tabellen of indexen toe zonder iets te verwijderen wat de huidige app nodig heeft. Contract is de opruimfase waarin je de oude kolommen, oude lees-/schrijfpaden en tijdelijke synchronisatielogica verwijdert nadat het nieuwe pad volledig werkt.
Een nullable kolom zonder default toevoegen is meestal het veiligste begin, omdat het zware locks vermijdt en oude code blijft werken. Deploy vervolgens code die kan omgaan met ontbrekende of NULL-waarden, backfill geleidelijk en verscherp later pas constraints zoals NOT NULL.
Dual-write gebruik je tijdens de transitie: de nieuwe appversie schrijft zowel naar het oude veld als naar het nieuwe veld. Zo blijft de data consistent zolang er oudere app-instanties of jobs zijn die alleen het oude veld kennen.
Backfill in kleine batches die snel afronden, en maak elke batch idempotent zodat herhalingen alleen rijen updaten die nog werk nodig hebben. Houd querytijden, lock-waits en replicatievertraging in de gaten en pauzeer of verklein de batches als de database te warm wordt.
Controleer eerst volledigheid, bijvoorbeeld hoeveel rijen nog NULL hebben in de nieuwe kolom. Doe daarna een consistentiecheck door oude en nieuwe waarden te vergelijken op een steekproef (of continu als het goedkoop is), en houd productie-errors na deploys in de gaten om paden te ontdekken die nog het verkeerde schema gebruiken.
NOT NULL of nieuwe constraints te vroeg toevoegen terwijl oudere appversies nog rijen zonder het nieuwe veld schrijven; een hele grote backfill in één transactie doen; aannemen dat een default geen kosten veroorzaakt (sommige defaults triggeren een table rewrite); reads switchen naar de nieuwe kolom terwijl writes die nog niet betrouwbaar vullen; of andere writers/lezers vergeten zoals cron-jobs, workers of rapportagequeries.
Stop met dual-write en bevestig dat nieuwe writes alleen in de nieuwe kolom landen; verwijder oude reads uit de applicatie zodat de fallback weg is; verwijder dode codepaden, featureflags en achtergrondjobs die het oude schema aanraken; verwijder tijdelijke triggers of synchronisatietaken; en drop oude indexen/constraints en dan de oude kolom.
Als je een maintenance window kunt tolereren en er weinig verkeer is, is een eenmalige migratie prima. Heb je echte gebruikers, meerdere app-instanties, achtergrondworkers of SLA's, dan is expand/contract meestal de moeite waard omdat het uitrols en rollbacks veiliger maakt; in Koder.ai Planning Mode helpt het opschrijven van fasen en checks je om de saaie maar noodzakelijke stappen niet over te slaan.