We shipped a table rename last week. Within minutes, Bugsnag lit up:
PG::UndefinedTable: ERROR: relation "chart_new_settings" does not exist
The code referencing that table doesn't exist in main anymore. So how did this happen?
The Heroku Race Condition
Heroku's deployment sequence creates a gap where old code runs against new schema:
1. Release phase runs (migrations execute)
2. Old dynos still serving traffic ← Problem window
3. New dynos boot
4. Traffic switches to new dynos
During step 2, the old code queries a table that no longer exists. The migration renamed it. The old dynos don't know that yet.
This isn't a Heroku bug. It's how the release phase works. Migrations run before new code deploys, but old dynos keep serving until new ones are ready.
What Doesn't Help
Heroku Preboot - Ensures new dynos are healthy before switching traffic, but doesn't change when migrations run. The race condition window is identical.
Pipeline Promotion - Promotes a pre-built slug from staging. Faster deploy (no build step), but the release phase timing is the same. Old dynos still serve while migrations run.
Judoscale / Autoscaling - Manages dyno count, not deployment orchestration. No effect on migration timing.
The Fix: PostgreSQL Views
Rename the table, but create a view with the old name pointing to the new table. Old code queries the view. New code queries the real table. Everyone's happy.
PR 1: Rename + View + Code Update
class RenameChartNewSettingsToChartSettings < ActiveRecord::Migration[8.0]
def up
rename_table :chart_new_settings, :chart_settings
execute "CREATE VIEW chart_new_settings AS SELECT * FROM chart_settings"
end
def down
execute "DROP VIEW chart_new_settings"
rename_table :chart_settings, :chart_new_settings
end
end
In the same PR, rename the model:
# app/models/chart_setting.rb (renamed from chart_new_setting.rb)
class ChartSetting < ApplicationRecord
# All the code, with updated references
end
During deploy:
1. Migration runs: table renamed, view created
2. Old dynos query chart_new_settings (view) → works
3. New dynos boot with ChartSetting → works
4. Traffic switches
Zero errors.
PR 2: Drop the View
class DropChartNewSettingsView < ActiveRecord::Migration[8.0]
def up
execute "DROP VIEW IF EXISTS chart_new_settings"
end
end
That's it. Two PRs. No downtime. No errors.
Why Views Work for This
PostgreSQL views based on simple SELECT * statements are updatable by default. Reads and writes both work through the view. The old code doesn't know or care that it's hitting a view instead of a table.
The view exists solely to handle the race condition window. Once all dynos are running new code, the view is unused. Drop it in the next deploy.
The General Rule
On Heroku, migrations must be backward-compatible with currently-running code:
| Migration Type | Safe? | Notes |
|---|---|---|
| Add column | Yes | Old code ignores new columns |
| Add table | Yes | Old code doesn't reference it |
| Add index | Yes | Old code doesn't care |
| Drop column | No | Deploy code removal first |
| Drop table | No | Deploy code removal first |
| Rename table | No | Use view pattern |
| Rename column | No | Use view or multi-phase |
If the migration would break old code, you need a compatibility layer.
What About AWS/Kubernetes?
The fundamental difference: you control deployment orchestration.
With blue-green deployment:
1. Deploy new code to green environment (not serving traffic)
2. Run migrations
3. New code is already running when schema changes
4. Switch load balancer to green
No race condition. New code and new schema go live together.
With Kubernetes init containers:
initContainers:
- name: run-migrations
command: ["rails", "db:migrate"]
containers:
- name: app
# Only starts after migrations succeed
The pod doesn't receive traffic until migrations complete AND new code is running.
Heroku's model—release phase before dyno restart—means you can't achieve this ordering without maintenance mode or backward-compatible migrations.
When to Use Maintenance Mode
For high-risk migrations where the view pattern doesn't apply (complex column renames, data transformations), maintenance mode is the honest choice:
heroku maintenance:on -a production
git push heroku main
# Wait for deploy
heroku maintenance:off -a production
Brief downtime beats production errors. For internal tools or low-traffic features, this is often the right call.
The Takeaway
Heroku's deployment model is opinionated. Fight it and you get race conditions. Work with it—backward-compatible migrations, view patterns, staged rollouts—and deploys are boring.
The table rename that triggered this post? One view. Two deploys. Zero drama. That's the goal.