Zero-Downtime Table Renames on Heroku

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.

← Back to all articles