- Knowledge
- technology
- OOP
- Tips
- Programming
- Tips
- Tutorial
- SEO
- Ranking
- Knowledge
- Special Day
- Seo
- Bug
- Data science
- Seo
- artificial intelligence
- Machine Learning
- Robotics
- happyNewYear2021
- newYearEve
- 2021
- Automation
- Smart Home
- Career
- Best Practices
- Git
- Logging
- Web Fundamentals
- DNS
- HTTPS
- Performance
- AI Tools
- ChatGPT
- Claude
- Gemini
- Laravel
- Eloquent
- MySQL
- HTTPS
- TLS
- Web Security
- Certificates
- Developer Life
- Debugging
- Docker
- DevOps
- Transactions
- Queues
- LLMs
- AI
- AI Coding
- Developer Tools
- React Native
- Expo
- Kate PMS
- Mobile Apps
- Laravel
- Authentication
- Sanctum
- Cookies
- API Design
- Payments
- Idempotency
- DeepSeek
- Open Source AI
- LLMs
- AI News
- Git
- Version Control
- AI Coding
- Prompting
- PHP
- Checklist
- MCP
- AI Agents
- OpenAI
- Architecture
- Microservices
- Modular Monolith
- Estimation
- Developer Life
- Project Planning
- Humour
- OAuth
- OpenID Connect
- Authentication
- Embeddings
- Vector Search
- RAG
- pgvector
- OpenAI
- GPT-4.1
- Codex CLI
- Events
- Testing
- Clean Code
- Maintainability
- Code Review
- Webhooks
- API
- Security
- Claude Code
- Workflow
- AI
- LLM
- Prompt Injection
- Mobile
- React
- Networking
- TCP
- UDP
- HTTP/3
- CLAUDE.md
- AWS
- Cloud Security
- Backups
- PHPUnit
- Software Engineering
- Leadership
- Communication
- RAG
- Embeddings
- AI Engineering
- IT Infrastructure
- Networking
- Access Control
- CI/CD
- GitHub Actions
- Gemini CLI
- Claude Code
- JavaScript
- Async/Await
- Node.js
- Promises
- Security
- Cryptography
- Passwords
- MySQL
- Database
- Vibe Coding
- Software Quality
- DNS
- Code Reading
- Onboarding
- Productivity
- Background Jobs
- Developer Humour
- Estimates
- Dev Life
- JWT
- o3-mini
- DeepSeek R1
- Rate Limiting
- Kate PMS
- E-Signing
- Audit Trail
- REST
- GraphQL
- API Design
- Laravel 12
- Upgrade Guide
- Open Source
- Self-Hosting
- Task Scheduling
- Cron
- Secrets
- CORS
- PHP
- PHP-FPM
- OPcache
- GitHub Copilot
- Software Architecture
- Engineering
- TypeScript
- JavaScript
- Type Safety
- AI Security
- React Native
- Product Design
- AI Agents
- Kiro
- Queues
- Redis
- RabbitMQ
- AWS SQS
- Nginx
- Apache
- GPT-5
- gpt-oss
- Clean Code
- Architecture
- Naming
- Documentation
- Career
- ADR
- Teamwork
- Supply Chain
- Kate HRM
- HR Software
- Permissions
- System Design
- Pagination
- SSH
- Linux
- Big O
- Databases
- Laravel Boost
- MCP
- Developer Skills
- Validation
- Databases
- Indexes
- Code Quality
- Deployment
- Developer Humour
- Feature Flags
- Code Review
- Pull Requests
- Docker
- Cursor
- Authorization
- RBAC
- Gemini
- Long Context
- PHP 8.4
- Caching
- Dependency Injection
- Web Performance
- Browser
- CSS
- Database
- Migrations
- ChatGPT
- AI for Developers
- Monitoring
- On-Call
- REST
- Backend
- SQL
- NoSQL
- Database Design
- Coding Agents
- Claude 4
- API Resources
- REST API
- Load Balancing
- Scaling
- AWS
- AI Tools
- Claude
- Sora 2
- CTE
- 2FA
- TOTP
- Programming Languages
- Prompts
- Developer Workflow
- API Gateway
- APIs
- Passport
- API Auth
- Learning
- Burnout
- Developer Growth
- Web Development
- SEO
- Kate Mall
- ChatGPT Atlas
- Agent Skills
- Middleware
- Laravel 12
- Collections
- Context Window
- Monitoring
- Commit Messages
- Self Review
- Growth
- Regex
- Programming Basics
- Text Processing
- Database Design
- Normalization
- Linux
- Server Security
- Linux Foundation
- Open Standards
- Legacy Code
- Documentation
- AI Workflow
- File Uploads
- Test Data
- Hashing
- Performance
- Caching
- Enums
- Scope Creep
- Estimation
- Codex
- Gemini CLI
- Timezones
- Carbon
- Bugs
- PHP 8.5
- Gemini 3
- GPT-5.1
- Data Integrity
- Event Loop
- Async
- Opus 4.5
- AI Models
- React
- Forms
- Frontend
- Backups
- AI Images
- DALL-E
- Midjourney
- Race Conditions
- Concurrency
- Legacy Code
- Refactoring
- Senior Engineer
- Scope
- LLM
- CDN
- Web
- Sub-Agents
- Soft Deletes
- Audit Log
- Concurrency
- AI Learning
- NestJS
- AI Evals
- Policies
- SPF DKIM DMARC
- Unicode
- UTF-8
- Knowledge Graph
- Value Objects
- Technical Debt
- Feature Flags
- Laravel Pennant
- Deployment
- Copilot
- Composer
- Dependencies
- Artisan
- Automation
- AWS S3
- Object Storage
- Cloud
- Small Language Models
- Ollama
- Production
- Sessions
- HTTP
- Mentoring
- SQL
- Virtual Machines
- Web Development
- HTTP/2
- QUIC
- Web Performance
- AI Integration
- LLM API
- SOLID
- OOP
- Hosting
- Serverless
- Merge Conflicts
- Temperature
- AI Development
- Reverse Proxy
- Nginx
- Infrastructure
- Verification
- Passkeys
- WebAuthn
- Teams
- Communication
- Stakeholders
- Monorepo
- CI/CD
- Versioning
- JSON Schema
- Livewire
- Inertia
- Meetings
- Distributed Systems
- Privacy
- Full-Stack
- T-Shaped Skills
- Money
- Notifications
- Web Security
- HTTP Headers
- CSP
- Function Calling
- Load Testing
- k6
- Data Extraction
- Debugging
- WebSockets
- SSE
- Real-Time
- Laravel Reverb
- Infrastructure as Code
- Terraform
- Side Projects
- Laravel Pint
- OpenAPI
- Swagger
- UX
- Multimodal
- Jest
- Pair Programming
- APIs
- Rate Limiting
- Resilience
- Dev Humour
- Design Tokens
- JWT
- API Keys
- Sessions
- PHPStan
- Rector
- Incidents
- Reporting
- Dashboards
- Zero Trust
- IAM
- Search
- Laravel Scout
- Junior Developers
- Mentoring
- Images
- WebP
- AVIF
- Bug Reports
- Let's Encrypt
- Design Docs
- Software Design
- Observers
- Replication
- Accountability
- Data Structures
- Reliability
- LLM Memory
- Error Handling
- Payments
- Payment Gateway
- Webhooks
- PCI DSS
- Observability
- OpenTelemetry
- Personal Brand
- Writing
- Conventions
- Dates
- Scheduling
- Disaster Recovery
- Compression
- Brotli
- Deadlines
- Developer Habits
- State Machines
- Tech Roles
- UUID
- ULID
- Horizon
- Planning
- Engineering Culture
- Ownership
- Soft Skills
- Socialite
- Cost Control
- Collations
- Unicode
- Octane
- PostgreSQL
Zero-Downtime Database Migrations: The Expand and Contract Pattern
About Post
Renaming a column is one line of code. Deployed the naive way, it's also a short outage you scheduled yourself.
The migration runs, the column is now called mobile_number, and for the next few seconds (or minutes) the code still running in production keeps asking for phone. Requests fail, queued jobs fail, and somebody's tenant app shows a friendly error screen. Nothing was wrong with the migration. The problem was the order of things.
The fix is a pattern with a slightly grand name: expand and contract.
Why deploys aren't atomic
We like to imagine a deploy as a switch: old version off, new version on. In reality there's always a window where old code and the new database schema meet:
- Migrations usually run before the new code goes live, so old code runs against the new schema for a while.
- With several servers behind a load balancer, they don't all switch at the same instant.
- Queue workers keep the old code in memory until they're restarted, and jobs already in the queue were created by the old code.
- If something goes wrong, you'll want to roll the code back, and the old code needs to work with whatever schema is there.
So the real rule is: every schema change must be compatible with both the version of the code before it and the version after it. Expand and contract is just a disciplined way of following that rule.
The pattern, step by step
Let's rename tenants.phone to tenants.mobile_number without anyone noticing. It takes several small deploys instead of one big one.
Step 1: Expand (add the new column, nullable)
return new class extends Migration
{
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->string('mobile_number', 20)->nullable()->after('phone');
});
}
};
Old code doesn't know the column exists, and doesn't care. Nullable is important: a NOT NULL column without a default would break every insert the old code makes.
Step 2: Write to both
Deploy code that writes to both columns but still reads the old one. A small model hook keeps it in one place:
protected static function booted(): void
{
static::saving(function (Tenant $tenant) {
if ($tenant->isDirty('phone')) {
$tenant->mobile_number = $tenant->phone;
}
});
}
From now on, every new or updated row has both values. Note that this only covers writes through Eloquent; if you have raw queries or bulk updates touching the column, they need the same treatment.
Step 3: Backfill the old rows
DB::table('tenants')
->whereNull('mobile_number')
->whereNotNull('phone')
->chunkById(500, function ($tenants) {
foreach ($tenants as $tenant) {
DB::table('tenants')
->where('id', $tenant->id)
->update(['mobile_number' => $tenant->phone]);
}
});
Run it as an Artisan command or a queued job, not inside the migration. Small batches keep each write short, so you don't hold locks for long or flood replication. Use chunkById, not chunk: when you update the same column you're filtering on, offset-based chunking skips rows.
Step 4: Switch reads
Deploy code that reads mobile_number everywhere: models, API resources, exports, reports. Keep writing both columns for one more release, so a rollback is still safe. The hook simply flips direction: the code now sets mobile_number, and the hook copies it back into phone.
Step 5: Contract (remove the old column)
Once nothing reads or writes phone, remove the dual-write hook in one deploy, then drop the column in the next:
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn('phone');
});
Waiting a release before dropping feels slow. It's the cheapest insurance you'll ever buy: if the release that switched reads has a problem, you can roll back without restoring data.
The rule: add before you use, stop using before you remove. Every step is boring on its own, and boring is exactly what you want from a production deploy.
The migrations that deserve a second look
Not every change needs five deploys. Adding a new nullable column for a new feature is already safe. These are the ones that deserve a pause before you merge:
| Change | Why it's risky | Safer approach |
|---|---|---|
| Rename a column or table | Old code still uses the old name | Expand and contract |
| Drop a column | Old code (and old queued jobs) may still read or write it | Remove from code first, drop a release later |
Add a NOT NULL column with no default | Old code's inserts fail | Add nullable, backfill, then tighten |
| Change a column's type | MySQL often rebuilds the whole table and blocks writes while it does | New column plus backfill, or an online schema tool |
| Add an index to a large table | Usually online in InnoDB, but still heavy I/O and replica lag | Run off-peak, watch replication |
| Add a unique constraint | Fails halfway if duplicates exist | Find and fix duplicates first |
A MySQL detail worth knowing
In MySQL 8, many changes are fast at the database level. Adding a column can be INSTANT (a metadata change, no table rebuild), and renaming a column doesn't copy the table either. That's good news, but it's also why people get caught out: the database part was quick, so they assumed the deploy was safe. The outage came from the code, not the lock.
The opposite surprise also happens: a change you expected to be quick quietly falls back to copying the whole table. If you want a migration to fail loudly instead, ask for the algorithm explicitly:
DB::statement(
'ALTER TABLE tenants ADD COLUMN mobile_number VARCHAR(20) NULL, ALGORITHM=INSTANT'
);
If MySQL can't do it instantly, it refuses with an error instead of locking your table. For very large tables where a copy is unavoidable, tools like gh-ost or pt-online-schema-change build the new table in the background and swap it in.
The checklist I run before merging a migration
- Will the currently deployed code still work after this migration runs?
- If I roll back the code (not the database), does the old version still work?
- Do queued jobs created by the old code still work?
- Does this rebuild or lock a large table? Have I checked the table size?
- Is the backfill a separate, batched, restartable step?
If the answer to any of those is "not sure", that's a sign to split the change into smaller deploys.
What's your team's rule for risky migrations? Do you split them into multiple deploys, or schedule a maintenance window and accept the downtime?

Be first to comment it...